diff --git a/NuGet.config b/NuGet.config
index 248d9280c..65cf28223 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -4,9 +4,4 @@
-
-
-
-
-
diff --git a/docs/api/console/samples/console-download-manifest.json b/docs/api/console/samples/console-download-manifest.json
new file mode 100644
index 000000000..eb85b7061
--- /dev/null
+++ b/docs/api/console/samples/console-download-manifest.json
@@ -0,0 +1,37 @@
+{
+ "version": "2025-12-07",
+ "exportId": "console-export::tenant-default::2025-12-07::0009",
+ "tenantId": "tenant-default",
+ "generatedAt": "2025-12-07T10:15:00Z",
+ "items": [
+ {
+ "type": "vuln",
+ "id": "CVE-2024-12345",
+ "format": "json",
+ "url": "https://downloads.local/exports/0009/vuln/CVE-2024-12345.json?sig=abc",
+ "sha256": "f1c5a94d5e7e0b12f8a6c3b9e2f3d1017c6b9c1c822f4d2d5fa0c3e46f0e9a10",
+ "size": 18432
+ },
+ {
+ "type": "vex",
+ "id": "vex:tenant-default:jwt-auth:5d1a",
+ "format": "ndjson",
+ "url": "https://downloads.local/exports/0009/vex/vex-tenant-default-jwt-auth-5d1a.ndjson?sig=def",
+ "sha256": "3a2d0edc2bfa4c5c9e1a7f96b0b5e6de378c1f9baf2d6f2a7e9c5d4b3f0c1a2e",
+ "size": 9216
+ },
+ {
+ "type": "bundle",
+ "id": "console-export::tenant-default::2025-12-07::0009",
+ "format": "tar.gz",
+ "url": "https://downloads.local/exports/0009/bundle.tar.gz?sig=ghi",
+ "sha256": "12ae34f51c2b4c6d7e8f90ab1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7081920a",
+ "size": 48732102
+ }
+ ],
+ "checksums": {
+ "manifest": "sha256:8bbf3cc1f8c7d6e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0dffeeddccbbaa99887",
+ "bundle": "sha256:12ae34f51c2b4c6d7e8f90ab1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7081920a"
+ },
+ "expiresAt": "2025-12-14T10:15:00Z"
+}
diff --git a/docs/api/console/search-downloads.md b/docs/api/console/search-downloads.md
new file mode 100644
index 000000000..6c4fc0e12
--- /dev/null
+++ b/docs/api/console/search-downloads.md
@@ -0,0 +1,58 @@
+# Console Search & Downloads · Draft v0.2
+
+Scope: unblock WEB-CONSOLE-23-004/005 by defining deterministic ranking, caching rules, and the download manifest structure (including signed metadata option) for console search and offline bundle downloads. Final guild sign-off still required.
+
+## 1) Deterministic search ranking
+- Primary sort: `severity (desc)` → `exploitScore (desc)` → `reachability (reachable > unknown > unreachable)` → `policyBadge (fail > warn > pass > waived)` → `vexState (under_investigation > fixed > not_affected > unknown)` → `findingId (asc)`.
+- Secondary tie-breakers (when above fields absent): `advisoryId (asc)` then `product (asc)`.
+- All pages are pre-sorted server-side; clients MUST NOT re-order.
+
+## 2) Caching + freshness
+- Response headers: `Cache-Control: public, max-age=300, stale-while-revalidate=60, stale-if-error=300`.
+- `ETag` is a stable SHA-256 over the sorted payload; clients send `If-None-Match` for revalidation.
+- `Last-Modified` reflects the newest `updatedAt` in the result set.
+- Retry/backoff guidance: honor `Retry-After` when present; default client backoff `1s,2s,4s,8s` capped at 30s.
+- Deterministic page cursors: opaque base64url, signed; include `sortKeys` and `tenant` to avoid cross-tenant reuse.
+
+## 3) Download manifest (for `/console/downloads` and export outputs)
+Top-level:
+```jsonc
+{
+ "version": "2025-12-07",
+ "exportId": "console-export::tenant-default::2025-12-07::0009",
+ "tenantId": "tenant-default",
+ "generatedAt": "2025-12-07T10:15:00Z",
+ "items": [
+ {
+ "type": "vuln", // advisory|vex|policy|scan|chart|bundle
+ "id": "CVE-2024-12345",
+ "format": "json",
+ "url": "https://downloads.local/exports/0009/vuln/CVE-2024-12345.json?sig=...",
+ "sha256": "f1c5…",
+ "size": 18432
+ }
+ ],
+ "checksums": {
+ "manifest": "sha256:8bbf…",
+ "bundle": "sha256:12ae…" // optional when a tar/zip bundle is produced
+ },
+ "expiresAt": "2025-12-14T10:15:00Z"
+}
+```
+
+### 3.1 Signed metadata
+- Optional DSSE envelope for `checksums.manifest`, using `sha256` digest and `application/json` payload type `stellaops.console.manifest`.
+- Envelope is attached as `manifest.dsse` or provided via `Link: <...>; rel="alternate"; type="application/dsse+json"`.
+- Signers: Authority-issued short-lived key scoped to `console:export`.
+
+### 3.2 Error handling
+- Known error codes: `ERR_CONSOLE_DOWNLOAD_INVALID_CURSOR`, `ERR_CONSOLE_DOWNLOAD_EXPIRED`, `ERR_CONSOLE_DOWNLOAD_RATE_LIMIT`, `ERR_CONSOLE_DOWNLOAD_UNAVAILABLE`.
+- On error, respond with deterministic JSON body including `requestId` and `retryAfterSeconds` when applicable.
+
+## 4) Sample manifest
+- `docs/api/console/samples/console-download-manifest.json` illustrates the exact shape above.
+
+## 5) Open items for guild sign-off
+- Final TTL values for `max-age` and `stale-*`.
+- Whether DSSE envelope is mandatory for sealed tenants.
+- Maximum bundle size / item count caps (proposal: 1000 items, 500 MiB compressed per export).
diff --git a/docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md b/docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md
index 8a52b7e8c..ca47bfef0 100644
--- a/docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md
+++ b/docs/implplan/SPRINT_0120_0001_0001_policy_reasoning.md
@@ -32,7 +32,7 @@
- **Wave A (observability + replay):** Tasks 0–2 DONE; metrics and harness frozen; keep schemas stable for downstream Ops/DevOps sprints.
- **Wave B (provenance exports):** Task 4 DONE; uses orchestrator export contract (now marked DONE). Keep linkage stable.
- **Wave C (air-gap provenance — COMPLETE):** Tasks 5–8 ALL DONE (2025-12-06). Staleness validation, evidence snapshots, and timeline impact events implemented.
-- **Wave D (attestation pointers):** Task 9 TODO; unblocked by `docs/schemas/attestation-pointer.schema.json`.
+- **Wave D (attestation pointers — COMPLETE):** Task 9 DONE (2025-12-07). Full attestation pointer infrastructure implemented.
- **Wave E (deployment collateral):** Task 3 BLOCKED pending DevOps paths for manifests/offline kit. Run after Wave C to avoid conflicting asset locations.
- Do not start blocked waves until dependencies land; avoid drift by keeping current DONE artifacts immutable.
@@ -61,11 +61,12 @@
| 6 | LEDGER-AIRGAP-56-002 | **DONE** (2025-12-06) | Implemented AirGapOptions, StalenessValidationService, staleness metrics. | Findings Ledger Guild, AirGap Time Guild / `src/Findings/StellaOps.Findings.Ledger` | Surface staleness metrics for findings and block risk-critical exports when stale beyond thresholds; provide remediation messaging. |
| 7 | LEDGER-AIRGAP-57-001 | **DONE** (2025-12-06) | Implemented EvidenceSnapshotService with cross-enclave verification. | Findings Ledger Guild, Evidence Locker Guild / `src/Findings/StellaOps.Findings.Ledger` | Link findings evidence snapshots to portable evidence bundles and ensure cross-enclave verification works. |
| 8 | LEDGER-AIRGAP-58-001 | **DONE** (2025-12-06) | Implemented AirgapTimelineService with timeline impact events. | Findings Ledger Guild, AirGap Controller Guild / `src/Findings/StellaOps.Findings.Ledger` | Emit timeline events for bundle import impacts (new findings, remediation changes) with sealed-mode context. |
-| 9 | LEDGER-ATTEST-73-001 | TODO | Unblocked: Attestation pointer schema at `docs/schemas/attestation-pointer.schema.json` | Findings Ledger Guild, Attestor Service Guild / `src/Findings/StellaOps.Findings.Ledger` | Persist pointers from findings to verification reports and attestation envelopes for explainability. |
+| 9 | LEDGER-ATTEST-73-001 | **DONE** (2025-12-07) | Implemented AttestationPointerService, PostgresAttestationPointerRepository, WebService endpoints, migration. | Findings Ledger Guild, Attestor Service Guild / `src/Findings/StellaOps.Findings.Ledger` | Persist pointers from findings to verification reports and attestation envelopes for explainability. |
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
+| 2025-12-07 | **LEDGER-ATTEST-73-001 DONE:** Implemented AttestationPointerRecord, IAttestationPointerRepository, PostgresAttestationPointerRepository, AttestationPointerService, WebService endpoints (POST/GET/PUT /v1/ledger/attestation-pointers), migration 008_attestation_pointers.sql, and unit tests. Added attestation.pointer_linked ledger event type and timeline logging. Wave D complete. | Implementer |
| 2025-12-06 | **LEDGER-ATTEST-73-001 Unblocked:** Changed from BLOCKED to TODO. Attestation pointer schema now available at `docs/schemas/attestation-pointer.schema.json`. Wave D can proceed. | Implementer |
| 2025-12-06 | **LEDGER-AIRGAP-56-002 DONE:** Implemented AirGapOptions (staleness config), StalenessValidationService (export blocking with ERR_AIRGAP_STALE), extended IAirgapImportRepository with staleness queries, added ledger_airgap_staleness_seconds and ledger_staleness_validation_failures_total metrics. | Implementer |
| 2025-12-06 | **LEDGER-AIRGAP-57-001 DONE:** Implemented EvidenceSnapshotRecord, IEvidenceSnapshotRepository, EvidenceSnapshotService with cross-enclave verification. Added airgap.evidence_snapshot_linked ledger event type and timeline logging. | Implementer |
diff --git a/docs/implplan/SPRINT_0120_0001_0002_excititor_ii.md b/docs/implplan/SPRINT_0120_0001_0002_excititor_ii.md
index 658538346..ac9b9881e 100644
--- a/docs/implplan/SPRINT_0120_0001_0002_excititor_ii.md
+++ b/docs/implplan/SPRINT_0120_0001_0002_excititor_ii.md
@@ -22,8 +22,8 @@
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 1 | EXCITITOR-CONSOLE-23-001/002/003 | DONE (2025-11-23) | Dependent APIs live | Excititor Guild · Docs Guild | Console VEX endpoints (grouped statements, counts, search) with provenance + RBAC; metrics for policy explain. |
-| 2 | EXCITITOR-CONN-SUSE-01-003 | TODO | Upstream EXCITITOR-CONN-SUSE-01-002; ATLN schema | Connector Guild (SUSE) | Emit trust config (signer fingerprints, trust tier) in provenance; aggregation-only. |
-| 3 | EXCITITOR-CONN-UBUNTU-01-003 | TODO | EXCITITOR-CONN-UBUNTU-01-002; ATLN schema | Connector Guild (Ubuntu) | Emit Ubuntu signing metadata in provenance; aggregation-only. |
+| 2 | EXCITITOR-CONN-SUSE-01-003 | **DONE** (2025-12-07) | Integrated ConnectorSignerMetadataEnricher in provenance | Connector Guild (SUSE) | Emit trust config (signer fingerprints, trust tier) in provenance; aggregation-only. |
+| 3 | EXCITITOR-CONN-UBUNTU-01-003 | **DONE** (2025-12-07) | Verified enricher integration, fixed Logger reference | Connector Guild (Ubuntu) | Emit Ubuntu signing metadata in provenance; aggregation-only. |
| 4 | EXCITITOR-CORE-AOC-19-002/003/004/013 | TODO | ATLN schema freeze | Excititor Core Guild | Deterministic advisory/PURL extraction, append-only linksets, remove consensus logic, seed Authority tenants in tests. |
| 5 | EXCITITOR-GRAPH-21-001..005 | TODO/BLOCKED | Link-Not-Merge schema + overlay contract | Excititor Core · Storage Mongo · UI Guild | Batched VEX fetches, overlay metadata, indexes/materialized views for graph inspector. |
| 6 | EXCITITOR-OBS-52/53/54 | TODO/BLOCKED | Evidence Locker DSSE + provenance schema | Excititor Core · Evidence Locker · Provenance Guilds | Timeline events + Merkle locker payloads + DSSE attestations for evidence batches. |
@@ -53,6 +53,7 @@
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
+| 2025-12-07 | **EXCITITOR-CONN-SUSE-01-003 & EXCITITOR-CONN-UBUNTU-01-003 DONE:** Integrated `ConnectorSignerMetadataEnricher.Enrich()` into both connectors' `AddProvenanceMetadata()` methods. This adds external signer metadata (fingerprints, issuer tier, bundle info) from `STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH` environment variable to VEX document provenance. Fixed Ubuntu connector's `_logger` → `Logger` reference bug. | Implementer |
| 2025-12-05 | Reconstituted sprint from `tasks-all.md`; prior redirect pointed to non-existent canonical. Added template and delivery tracker; tasks set per backlog. | Project Mgmt |
| 2025-11-23 | Console VEX endpoints (tasks 1) delivered. | Excititor Guild |
diff --git a/docs/implplan/SPRINT_0121_0001_0002_policy_reasoning_blockers.md b/docs/implplan/SPRINT_0121_0001_0002_policy_reasoning_blockers.md
index 01add0ff3..3ba6a2916 100644
--- a/docs/implplan/SPRINT_0121_0001_0002_policy_reasoning_blockers.md
+++ b/docs/implplan/SPRINT_0121_0001_0002_policy_reasoning_blockers.md
@@ -26,16 +26,18 @@
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 1 | LEDGER-ATTEST-73-002 | BLOCKED | Waiting on LEDGER-ATTEST-73-001 verification pipeline delivery | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Enable search/filter in findings projections by verification result and attestation status |
-| 2 | LEDGER-OAS-61-001-DEV | TODO | Unblocked: OAS baseline available at `docs/schemas/findings-ledger-api.openapi.yaml` | Findings Ledger Guild; API Contracts Guild / `src/Findings/StellaOps.Findings.Ledger` | Expand Findings Ledger OAS to include projections, evidence lookups, and filter parameters with examples |
+| 2 | LEDGER-OAS-61-001-DEV | **DONE** (2025-12-07) | Expanded OAS with attestation pointer endpoints, schemas, and examples | Findings Ledger Guild; API Contracts Guild / `src/Findings/StellaOps.Findings.Ledger` | Expand Findings Ledger OAS to include projections, evidence lookups, and filter parameters with examples |
| 3 | LEDGER-OAS-61-002-DEV | BLOCKED | PREP-LEDGER-OAS-61-002-DEPENDS-ON-61-001-CONT | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Implement `/.well-known/openapi` endpoint and ensure version metadata matches release |
| 4 | LEDGER-OAS-62-001-DEV | BLOCKED | PREP-LEDGER-OAS-62-001-SDK-GENERATION-PENDING | Findings Ledger Guild; SDK Generator Guild / `src/Findings/StellaOps.Findings.Ledger` | Provide SDK test cases for findings pagination, filtering, evidence links; ensure typed models expose provenance |
| 5 | LEDGER-OAS-63-001-DEV | BLOCKED | PREP-LEDGER-OAS-63-001-DEPENDENT-ON-SDK-VALID | Findings Ledger Guild; API Governance Guild / `src/Findings/StellaOps.Findings.Ledger` | Support deprecation headers and Notifications for retiring finding endpoints |
| 6 | LEDGER-OBS-55-001 | BLOCKED | PREP-LEDGER-OBS-55-001-DEPENDS-ON-54-001-ATTE | Findings Ledger Guild; DevOps Guild / `src/Findings/StellaOps.Findings.Ledger` | Enhance incident mode to record replay diagnostics (lag traces, conflict snapshots), extend retention while active, and emit activation events to timeline/notifier |
-| 7 | LEDGER-PACKS-42-001-DEV | TODO | Unblocked: Time-travel API available at `docs/schemas/ledger-time-travel-api.openapi.yaml` | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Provide snapshot/time-travel APIs and digestible exports for task pack simulation and CLI offline mode |
+| 7 | LEDGER-PACKS-42-001-DEV | **DONE** (2025-12-07) | Implemented snapshot/time-travel APIs with full endpoint coverage | Findings Ledger Guild / `src/Findings/StellaOps.Findings.Ledger` | Provide snapshot/time-travel APIs and digestible exports for task pack simulation and CLI offline mode |
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
+| 2025-12-07 | **LEDGER-PACKS-42-001-DEV DONE:** Implemented full snapshot/time-travel API infrastructure: (1) Domain models in SnapshotModels.cs (LedgerSnapshot, QueryPoint, TimeQueryFilters, ReplayRequest, DiffRequest, ChangeLogEntry, StalenessResult, etc.); (2) Repository interfaces ISnapshotRepository and ITimeTravelRepository; (3) PostgreSQL implementations PostgresSnapshotRepository and PostgresTimeTravelRepository; (4) SnapshotService orchestrating all time-travel operations; (5) WebService contracts in SnapshotContracts.cs; (6) 13 new API endpoints (/v1/ledger/snapshots CRUD, /v1/ledger/time-travel/{findings,vex,advisories}, /v1/ledger/replay, /v1/ledger/diff, /v1/ledger/changelog, /v1/ledger/staleness, /v1/ledger/current-point); (7) Database migration 009_snapshots.sql; (8) Unit tests in SnapshotServiceTests.cs with in-memory repository mocks. | Implementer |
+| 2025-12-07 | **LEDGER-OAS-61-001-DEV DONE:** Expanded `docs/schemas/findings-ledger-api.openapi.yaml` with attestation pointer endpoints (/attestation-pointers, /findings/{findingId}/attestation-pointers, /findings/{findingId}/attestation-summary), comprehensive schemas (AttestationPointer, AttestationRefDetail, SignerInfo, RekorEntryRef, VerificationResult, VerificationCheck, AttestationSummary), and request/response examples for search, create, and update operations. | Implementer |
| 2025-12-06 | **Wave A/C Partial Unblock:** LEDGER-OAS-61-001-DEV and LEDGER-PACKS-42-001-DEV changed from BLOCKED to TODO. Root blockers resolved: OAS baseline at `docs/schemas/findings-ledger-api.openapi.yaml`, time-travel API at `docs/schemas/ledger-time-travel-api.openapi.yaml`. | Implementer |
| 2025-12-03 | Added Wave Coordination outlining contract/incident/pack waves; statuses unchanged (all remain BLOCKED). | Project Mgmt |
| 2025-11-25 | Carried forward all BLOCKED Findings Ledger items from Sprint 0121-0001-0001; no status changes until upstream contracts land. | Project Mgmt |
diff --git a/docs/implplan/SPRINT_0146_0001_0001_scanner_analyzer_gap_close.md b/docs/implplan/SPRINT_0146_0001_0001_scanner_analyzer_gap_close.md
new file mode 100644
index 000000000..238a3464c
--- /dev/null
+++ b/docs/implplan/SPRINT_0146_0001_0001_scanner_analyzer_gap_close.md
@@ -0,0 +1,47 @@
+# Sprint 0146 · Scanner Analyzer Gap Closure
+
+## Topic & Scope
+- Close Amber/Red items in scanner analyzer readiness (Java/.NET validation, PHP pipeline, Node Phase22 CI, runtime parity).
+- Decide on bun.lockb stance and reconcile Deno status discrepancies; publish Dart/Swift scope notes.
+- Produce CI evidence (TRX/binlogs), fixtures, and doc updates to mark readiness green.
+- **Working directory:** `src/Scanner`.
+
+## Dependencies & Concurrency
+- Requires dedicated clean CI runner for Java/.NET/Node Phase22 validation.
+- Coordinate with Concelier/Signals guilds for PHP autoload graph and runtime evidence mapping.
+- Safe to run in parallel with non-scanner sprints; uses isolated runners and docs under `docs/modules/scanner`.
+
+## Documentation Prerequisites
+- `docs/modules/scanner/architecture.md`
+- `docs/modules/scanner/readiness-checkpoints.md`
+- `docs/modules/scanner/php-analyzer-owner-manifest.md`
+- `docs/modules/scanner/bun-analyzer-gotchas.md`
+- `docs/reachability/DELIVERY_GUIDE.md` (runtime parity sections)
+
+## Delivery Tracker
+| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
+| --- | --- | --- | --- | --- | --- |
+| 1 | SCAN-JAVA-VAL-0146-01 | TODO | Allocate clean runner; rerun Java analyzer suite and attach TRX/binlogs; update readiness to Green if passing. | Scanner · CI | Validate Java analyzer chain (21-005..011) on clean runner and publish evidence. |
+| 2 | SCAN-DOTNET-DESIGN-0146-02 | TODO | Finalize analyzer design 11-001; create fixtures/tests; CI run. | Scanner · CI | Unblock .NET analyzer chain (11-001..005) with design doc, fixtures, and passing CI evidence. |
+| 3 | SCAN-PHP-DESIGN-0146-03 | TODO | Composer/autoload spec + restore stability; new fixtures. | Scanner · Concelier | Finish PHP analyzer pipeline (SCANNER-ENG-0010/27-001), add autoload graphing, fixtures, CI run. |
+| 4 | SCAN-NODE-PH22-CI-0146-04 | TODO | Clean runner with trimmed graph; run `scripts/run-node-phase22-smoke.sh`; capture logs. | Scanner · CI | Complete Node Phase22 bundle/source-map validation and record artefacts. |
+| 5 | SCAN-DENO-STATUS-0146-05 | TODO | Reconcile readiness vs TASKS.md; add validation evidence if shipped. | Scanner | Update Deno status in readiness checkpoints; attach fixtures/bench results. |
+| 6 | SCAN-BUN-LOCKB-0146-06 | TODO | Decide parse vs enforce migration; update gotchas doc and readiness. | Scanner | Define bun.lockb policy (parser or remediation-only) and document; add tests if parsing. |
+| 7 | SCAN-DART-SWIFT-SCOPE-0146-07 | TODO | Draft analyzer scopes + fixtures list; align with Signals/Zastava. | Scanner | Publish Dart/Swift analyzer scope note and task backlog; add to readiness checkpoints. |
+| 8 | SCAN-RUNTIME-PARITY-0146-08 | TODO | Identify runtime hook gaps for Java/.NET/PHP; create implementation plan. | Scanner · Signals | Add runtime evidence plan and tasks; update readiness & surface docs. |
+
+## Execution Log
+| Date (UTC) | Update | Owner |
+| --- | --- | --- |
+| 2025-12-07 | Sprint created to consolidate scanner analyzer gap closure tasks. | Planning |
+
+## Decisions & Risks
+- CI runner availability may delay Java/.NET/Node validation; mitigate by reserving dedicated runner slice.
+- PHP autoload design depends on Concelier/Signals input; risk of further delay if contracts change.
+- bun.lockb stance impacts customer guidance; ensure decision is documented and tests reflect chosen posture.
+- Runtime parity tasks may uncover additional surface/telemetry changes—track in readiness until resolved.
+
+## Next Checkpoints
+- 2025-12-10: CI runner allocation decision.
+- 2025-12-14: Status review on Java/.NET/Node validations and PHP design.
+- 2025-12-21: Final readiness update and doc sync across scanner module.
diff --git a/docs/implplan/SPRINT_0212_0001_0001_web_i.md b/docs/implplan/SPRINT_0212_0001_0001_web_i.md
index e52acc130..5186784b3 100644
--- a/docs/implplan/SPRINT_0212_0001_0001_web_i.md
+++ b/docs/implplan/SPRINT_0212_0001_0001_web_i.md
@@ -64,7 +64,7 @@
| # | Action | Owner | Due | Status |
| --- | --- | --- | --- | --- |
| 1 | Publish console export bundle orchestration contract + manifest schema and streaming limits; add samples to `docs/api/console/samples/`. | Policy Guild · Console Guild | 2025-12-08 | DOING (draft published, awaiting guild sign-off) |
-| 2 | Define caching/tie-break rules and download manifest format (signed metadata) for `/console/search` + `/console/downloads`. | Policy Guild · DevOps Guild | 2025-12-09 | TODO |
+| 2 | Define caching/tie-break rules and download manifest format (signed metadata) for `/console/search` + `/console/downloads`. | Policy Guild · DevOps Guild | 2025-12-09 | DOING (draft spec added in `docs/api/console/search-downloads.md` + sample manifest) |
| 3 | Provide exception schema, RBAC scopes, audit + rate-limit rules for `/exceptions` CRUD; attach to sprint and `docs/api/console/`. | Policy Guild · Platform Events | 2025-12-09 | TODO |
| 4 | Restore PTY/shell capacity on web host (openpty exhaustion) to allow tests/builds. | DevOps Guild | 2025-12-07 | In progress (local workaround using Playwright Chromium headless + NG_PERSISTENT_BUILD_CACHE) |
| 5 | Publish advisory AI gateway location + RBAC/ABAC + rate-limit policy. | BE-Base Platform | 2025-12-08 | TODO |
@@ -87,6 +87,7 @@
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
+| 2025-12-07 | Drafted caching/tie-break rules and download manifest spec for `/console/search` and `/console/downloads`; added `docs/api/console/search-downloads.md` and sample `docs/api/console/samples/console-download-manifest.json`. Awaiting Policy/DevOps sign-off; keeps WEB-CONSOLE-23-004/005 formally BLOCKED until approved. | Project Mgmt |
| 2025-12-07 | WEB-CONSOLE-23-003: console export client, store, and service specs now runnable locally using Playwright Chromium headless and `NG_PERSISTENT_BUILD_CACHE=1`; command: `CHROME_BIN=$HOME/.cache/ms-playwright/chromium-1140/chrome-linux/chrome NG_PERSISTENT_BUILD_CACHE=1 npm test -- --watch=false --browsers=ChromeHeadlessOffline --progress=false --include src/app/core/api/console-export.client.spec.ts,src/app/core/console/console-export.store.spec.ts,src/app/core/console/console-export.service.spec.ts`. Tests pass; backend contract still draft. | Implementer |
| 2025-12-04 | WEB-CONSOLE-23-002 completed: wired `console/status` route in `app.routes.ts`; created sample payloads `console-status-sample.json` and `console-run-stream-sample.ndjson` in `docs/api/console/samples/` verified against `ConsoleStatusDto` and `ConsoleRunEventDto` contracts. | BE-Base Platform Guild |
| 2025-12-02 | WEB-CONSOLE-23-002: added trace IDs on status/stream calls, heartbeat + exponential backoff reconnect in console run stream service, and new client/service unit tests. Backend commands still not run locally (disk constraint). | BE-Base Platform Guild |
diff --git a/docs/implplan/SPRINT_0514_0001_0002_ru_crypto_validation.md b/docs/implplan/SPRINT_0514_0001_0002_ru_crypto_validation.md
index b2f74e628..e142db9eb 100644
--- a/docs/implplan/SPRINT_0514_0001_0002_ru_crypto_validation.md
+++ b/docs/implplan/SPRINT_0514_0001_0002_ru_crypto_validation.md
@@ -14,6 +14,8 @@
- docs/implplan/SPRINT_0514_0001_0001_sovereign_crypto_enablement.md
- docs/contracts/crypto-provider-registry.md
- docs/contracts/authority-crypto-provider.md
+- docs/legal/crypto-compliance-review.md (unblocks RU-CRYPTO-VAL-05/06)
+- docs/security/wine-csp-loader-design.md (technical design for Wine approach)
## Delivery Tracker
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
@@ -31,11 +33,15 @@
| --- | --- | --- |
| 2025-12-06 | Sprint created; awaiting staffing. | Planning |
| 2025-12-06 | Re-scoped: proceed with Linux OpenSSL GOST baseline (tasks 1–3 set to TODO); CSP/Wine/Legal remain BLOCKED (tasks 4–7). | Implementer |
+| 2025-12-07 | Published `docs/legal/crypto-compliance-review.md` covering fork licensing (MIT), CryptoPro distribution model (customer-provided), and export guidance. Provides partial unblock for RU-CRYPTO-VAL-05/06 pending legal sign-off. | Security |
+| 2025-12-07 | Published `docs/security/wine-csp-loader-design.md` with three architectural approaches for Wine CSP integration: (A) Full Wine environment, (B) Winelib bridge, (C) Wine RPC server (recommended). Includes validation scripts and CI integration plan. | Security |
## Decisions & Risks
- Windows CSP availability may slip; mitigation: document manual runner setup and allow deferred close on #1/#6 (currently blocking).
- Licensing/export could block redistribution; must finalize before RootPack publish (currently blocking task 3).
- Cross-platform determinism must be proven; if mismatch, block release until fixed; currently waiting on #1/#2 data.
+- **Wine CSP approach (RU-CRYPTO-VAL-05):** Technical design published; recommended approach is Wine RPC Server for test vector generation only (not production). Requires legal review of CryptoPro EULA before implementation. See `docs/security/wine-csp-loader-design.md`.
+- **Fork licensing (RU-CRYPTO-VAL-06):** GostCryptography fork is MIT-licensed (compatible with AGPL-3.0). CryptoPro CSP is customer-provided. Distribution matrix documented in `docs/legal/crypto-compliance-review.md`. Awaiting legal sign-off.
## Next Checkpoints
- 2025-12-10 · Runner availability go/no-go.
diff --git a/docs/implplan/SPRINT_0516_0001_0001_cn_sm_crypto_enablement.md b/docs/implplan/SPRINT_0516_0001_0001_cn_sm_crypto_enablement.md
index ba760be83..95c981902 100644
--- a/docs/implplan/SPRINT_0516_0001_0001_cn_sm_crypto_enablement.md
+++ b/docs/implplan/SPRINT_0516_0001_0001_cn_sm_crypto_enablement.md
@@ -20,7 +20,7 @@
| --- | --- | --- | --- | --- | --- |
| 1 | SM-CRYPTO-01 | DONE (2025-12-06) | None | Security · Crypto | Implement `StellaOps.Cryptography.Plugin.SmSoft` provider using BouncyCastle SM2/SM3 (software-only, non-certified); env guard `SM_SOFT_ALLOWED` added. |
| 2 | SM-CRYPTO-02 | DONE (2025-12-06) | After #1 | Security · BE (Authority/Signer) | Wire SM soft provider into DI (registered), compliance docs updated with “software-only” caveat. |
-| 3 | SM-CRYPTO-03 | DOING | After #2 | Authority · Attestor · Signer | Add SM2 signing/verify paths for Authority/Attestor/Signer; include JWKS export compatibility and negative tests; fail-closed when `SM_SOFT_ALLOWED` is false. Authority SM2 loader + JWKS tests done; Signer SM2 gate/tests added. Attestor wiring still pending. |
+| 3 | SM-CRYPTO-03 | DONE (2025-12-07) | After #2 | Authority · Attestor · Signer | Add SM2 signing/verify paths for Authority/Attestor/Signer; include JWKS export compatibility and negative tests; fail-closed when `SM_SOFT_ALLOWED` is false. Authority SM2 loader + JWKS tests done; Signer SM2 gate/tests added; Attestor SM2 wiring complete (SmSoftCryptoProvider registered, key loading, signing tests). |
| 4 | SM-CRYPTO-04 | DONE (2025-12-06) | After #1 | QA · Security | Deterministic software test vectors (sign/verify, hash) added in unit tests; “non-certified” banner documented. |
| 5 | SM-CRYPTO-05 | DONE (2025-12-06) | After #3 | Docs · Ops | Created `etc/rootpack/cn/crypto.profile.yaml` with cn-soft profile preferring `cn.sm.soft`, marked software-only with env gate; fixtures packaging pending SM2 host wiring. |
| 6 | SM-CRYPTO-06 | BLOCKED (2025-12-06) | Hardware token available | Security · Crypto | Add PKCS#11 SM provider and rerun vectors with certified hardware; replace “software-only” label when certified. |
@@ -34,6 +34,7 @@
| 2025-12-06 | Added cn rootpack profile (software-only, env-gated); set task 5 to DONE; task 3 remains TODO pending host wiring. | Implementer |
| 2025-12-06 | Started host wiring for SM2: Authority file key loader now supports SM2 raw keys; JWKS tests include SM2; task 3 set to DOING. | Implementer |
| 2025-12-06 | Signer SM2 gate + tests added (software registry); Attestor wiring pending. Sm2 tests blocked by existing package restore issues (NU1608/fallback paths). | Implementer |
+| 2025-12-07 | Attestor SM2 wiring complete: SmSoftCryptoProvider registered in AttestorSigningKeyRegistry, SM2 key loading (PEM/base64/hex), signing tests added. Fixed AWSSDK version conflict and pre-existing test compilation issues. Task 3 set to DONE. | Implementer |
## Decisions & Risks
- SM provider licensing/availability uncertain; mitigation: software fallback with “non-certified” label until hardware validated.
diff --git a/docs/legal/crypto-compliance-review.md b/docs/legal/crypto-compliance-review.md
new file mode 100644
index 000000000..363ebd7e8
--- /dev/null
+++ b/docs/legal/crypto-compliance-review.md
@@ -0,0 +1,257 @@
+# Crypto Compliance Review · License & Export Analysis
+
+**Status:** DRAFT
+**Date:** 2025-12-07
+**Owners:** Security Guild, Legal
+**Unblocks:** RU-CRYPTO-VAL-05, RU-CRYPTO-VAL-06
+
+## Overview
+
+This document captures the licensing, export controls, and distribution guidance for cryptographic components in StellaOps, specifically:
+
+1. **GostCryptography Fork** (`third_party/forks/AlexMAS.GostCryptography`)
+2. **CryptoPro Plugin** (`StellaOps.Cryptography.Plugin.CryptoPro`)
+3. **Regional Crypto Providers** (GOST, SM2/SM3, eIDAS)
+
+## 1. GostCryptography Fork
+
+### 1.1 License
+
+| Attribute | Value |
+|-----------|-------|
+| Upstream | https://github.com/AlexMAS/GostCryptography |
+| License | MIT |
+| StellaOps Usage | Source-vendored in `third_party/forks/` |
+| Compatibility | MIT is compatible with AGPL-3.0-or-later |
+
+### 1.2 Attribution Requirements
+
+The MIT license requires attribution in distributed software:
+
+```
+Copyright (c) 2014-2024 AlexMAS
+See third_party/forks/AlexMAS.GostCryptography/LICENSE
+```
+
+**Required Actions:**
+- [x] Keep `LICENSE` file in fork directory
+- [ ] Add attribution to `NOTICE.md` in repository root
+- [ ] Include attribution in RootPack_RU bundle documentation
+
+### 1.3 Distribution Guidance
+
+| Distribution Channel | Allowed | Notes |
+|---------------------|---------|-------|
+| StellaOps Source | Yes | Fork stays vendored |
+| RootPack_RU Bundle | Yes | Source + binaries allowed |
+| Public NuGet | **No** | Do not publish as standalone package |
+| Container Images | Yes | With source attribution |
+
+## 2. CryptoPro CSP Plugin
+
+### 2.1 License
+
+| Attribute | Value |
+|-----------|-------|
+| Vendor | CryptoPro LLC (crypto-pro.ru) |
+| Product | CryptoPro CSP 5.0 |
+| License Type | Commercial (per-deployment) |
+| Cost | Varies by tier (~$50-200 USD per instance) |
+
+### 2.2 Distribution Model
+
+CryptoPro CSP is **not redistributable** by StellaOps. The distribution model is:
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Distribution Model │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ StellaOps ships: │
+│ ├── Plugin source code (AGPL-3.0-or-later) │
+│ ├── Interface bindings to CryptoPro CSP │
+│ └── Documentation for customer-provided CSP installation │
+│ │
+│ Customer provides: │
+│ ├── CryptoPro CSP license │
+│ ├── CSP binaries installed on target system │
+│ └── PKCS#11 module path configuration │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### 2.3 Configuration for Customer-Provided CSP
+
+```yaml
+# etc/authority.yaml - Customer configures CSP path
+crypto:
+ pkcs11:
+ library_path: /opt/cprocsp/lib/amd64/libcapi20.so # Customer-provided
+ slot_id: 0
+ pin_env: AUTHORITY_HSM_PIN
+```
+
+### 2.4 Documentation Requirements
+
+- [ ] Document that CSP is "customer-provided" in installation guide
+- [ ] Add EULA notice that CSP licensing is customer responsibility
+- [ ] Include CSP version compatibility matrix (CSP 4.0/5.0)
+
+## 3. Export Control Analysis
+
+### 3.1 Applicable Regulations
+
+| Regulation | Jurisdiction | Relevance |
+|------------|--------------|-----------|
+| EAR (Export Administration Regulations) | USA | Crypto export controls |
+| Wassenaar Arrangement | 42 countries | Dual-use goods |
+| EU Dual-Use Regulation | EU | Crypto controls |
+| Russian Export Controls | Russia | GOST algorithm distribution |
+
+### 3.2 Algorithm Classification
+
+| Algorithm | Classification | Notes |
+|-----------|---------------|-------|
+| ECDSA P-256/P-384 | Mass-market exempt | Widely available |
+| RSA 2048+ | Mass-market exempt | Widely available |
+| EdDSA (Ed25519) | Mass-market exempt | Widely available |
+| GOST R 34.10-2012 | Regional use | See Section 3.3 |
+| SM2/SM3 | Regional use | Chinese national standard |
+
+### 3.3 GOST Algorithm Guidance
+
+GOST algorithms (GOST R 34.10-2012, GOST R 34.11-2012) are:
+
+- **Not export-controlled** from Russia when used in commercial software
+- **May be restricted** for import into certain jurisdictions
+- **Recommended** for use only in RootPack_RU deployments targeting Russian customers
+
+**Guidance:**
+1. Default StellaOps distribution does NOT include GOST algorithms enabled
+2. RootPack_RU is a separate distribution with GOST opt-in
+3. Document that customers are responsible for compliance with local crypto regulations
+
+### 3.4 Distribution Matrix
+
+| Component | Global | RootPack_RU | RootPack_CN | Notes |
+|-----------|--------|-------------|-------------|-------|
+| Core StellaOps | Yes | Yes | Yes | ECDSA/RSA/EdDSA |
+| GostCryptography Fork | Source only | Source + Binary | No | MIT license |
+| CryptoPro Plugin | Interface only | Interface + docs | No | Customer-provided CSP |
+| SM2/SM3 Plugin | No | No | Interface + docs | Customer-provided HSM |
+
+## 4. EULA and Notice Requirements
+
+### 4.1 NOTICE.md Addition
+
+Add to repository `NOTICE.md`:
+
+```markdown
+## Third-Party Cryptographic Components
+
+### GostCryptography (MIT License)
+Copyright (c) 2014-2024 AlexMAS
+https://github.com/AlexMAS/GostCryptography
+
+This software includes a forked version of the GostCryptography library
+for GOST algorithm support. The fork is located at:
+third_party/forks/AlexMAS.GostCryptography/
+
+### CryptoPro CSP Integration
+The CryptoPro CSP plugin provides integration with CryptoPro CSP software.
+CryptoPro CSP is commercial software and must be licensed separately by
+the end user. StellaOps does not distribute CryptoPro CSP binaries.
+```
+
+### 4.2 Installation Guide Addition
+
+Add to installation documentation:
+
+```markdown
+## Regional Crypto Support (Optional)
+
+### Russian Federation (RootPack_RU)
+
+StellaOps supports GOST R 34.10-2012 signing through integration with
+CryptoPro CSP. This integration requires:
+
+1. A valid CryptoPro CSP license (obtained separately from crypto-pro.ru)
+2. CryptoPro CSP 4.0 or 5.0 installed on the target system
+3. Configuration of the PKCS#11 module path
+
+**Note:** CryptoPro CSP is commercial software. StellaOps provides only
+the integration plugin; the CSP runtime must be licensed and installed
+by the customer.
+```
+
+## 5. CI/Testing Implications
+
+### 5.1 Test Environment Requirements
+
+| Environment | CSP Required | Legal Status |
+|-------------|--------------|--------------|
+| Development (Linux) | No | OpenSSL GOST engine fallback |
+| CI (Linux) | No | Mock/skip CSP tests |
+| CI (Windows opt-in) | Yes | Customer/StellaOps license |
+| Production | Customer | Customer license |
+
+### 5.2 CI Guard Implementation
+
+Tests are guarded by environment variable:
+
+```csharp
+[Fact]
+[SkipUnless("STELLAOPS_CRYPTO_PRO_ENABLED", "1")]
+public async Task CryptoProSigner_SignsWithGost()
+{
+ // Test only runs when CSP is available and licensed
+}
+```
+
+### 5.3 Wine Loader Experiment (RU-CRYPTO-VAL-05)
+
+**Status:** BLOCKED pending legal review
+
+Running CryptoPro CSP DLLs under Wine for cross-platform testing:
+
+| Consideration | Assessment |
+|---------------|------------|
+| Technical Feasibility | Uncertain - CSP uses Windows APIs |
+| Legal Permissibility | Requires CryptoPro EULA review |
+| Recommendation | Defer to Windows-only testing |
+
+**Decision:** Do not pursue Wine loader approach until/unless CryptoPro explicitly permits this use case in their EULA.
+
+## 6. Action Items
+
+### Immediate (unblocks RU-CRYPTO-VAL-06)
+
+- [x] Document fork licensing (MIT) ← This document
+- [x] Document CryptoPro distribution model ← This document
+- [ ] Add attribution to NOTICE.md
+- [ ] Update installation guide with CSP requirements
+
+### Short-term
+
+- [ ] Review CryptoPro EULA for Wine usage (if needed)
+- [ ] Create regional distribution manifests for RootPack_RU
+- [ ] Add compliance checkboxes to RootPack_RU installation
+
+### For Legal Sign-off
+
+- [ ] Confirm MIT + AGPL-3.0 compatibility statement
+- [ ] Confirm customer-provided model for CSP is acceptable
+- [ ] Review export control applicability for GOST distribution
+
+## 7. Sign-off Log
+
+| Role | Name | Date | Notes |
+|------|------|------|-------|
+| Security Guild | | | |
+| Legal | | | |
+| Product | | | |
+
+---
+
+*Document Version: 1.0.0*
+*Last Updated: 2025-12-07*
diff --git a/docs/schemas/findings-ledger-api.openapi.yaml b/docs/schemas/findings-ledger-api.openapi.yaml
index 48ff471fd..f7caf7d47 100644
--- a/docs/schemas/findings-ledger-api.openapi.yaml
+++ b/docs/schemas/findings-ledger-api.openapi.yaml
@@ -219,6 +219,240 @@ paths:
schema:
$ref: '#/components/schemas/AttestationListResponse'
+ /findings/{findingId}/attestation-pointers:
+ get:
+ operationId: getFindingAttestationPointers
+ summary: Get attestation pointers linking finding to verification reports and attestation envelopes
+ description: |
+ Returns all attestation pointers for a finding. Attestation pointers link findings
+ to verification reports, DSSE envelopes, SLSA provenance, VEX attestations, and other
+ cryptographic evidence for explainability and audit trails.
+ tags: [findings, attestation]
+ parameters:
+ - $ref: '#/components/parameters/FindingId'
+ - $ref: '#/components/parameters/TenantId'
+ responses:
+ '200':
+ description: List of attestation pointers
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/AttestationPointer'
+ examples:
+ verified_finding:
+ summary: Finding with verified DSSE envelope
+ value:
+ - pointer_id: "a1b2c3d4-5678-90ab-cdef-123456789abc"
+ finding_id: "f1234567-89ab-cdef-0123-456789abcdef"
+ attestation_type: "DsseEnvelope"
+ relationship: "VerifiedBy"
+ attestation_ref:
+ digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd"
+ storage_uri: "s3://attestations/envelope.json"
+ payload_type: "application/vnd.in-toto+json"
+ predicate_type: "https://slsa.dev/provenance/v1"
+ signer_info:
+ issuer: "https://fulcio.sigstore.dev"
+ subject: "build@stella-ops.org"
+ verification_result:
+ verified: true
+ verified_at: "2025-01-01T12:00:00Z"
+ verifier: "cosign"
+ verifier_version: "2.2.3"
+ checks:
+ - check_type: "SignatureValid"
+ passed: true
+ - check_type: "CertificateValid"
+ passed: true
+ created_at: "2025-01-01T10:00:00Z"
+ created_by: "scanner-service"
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /findings/{findingId}/attestation-summary:
+ get:
+ operationId: getFindingAttestationSummary
+ summary: Get summary of attestations for a finding
+ description: Returns aggregate counts and verification status for all attestations linked to a finding.
+ tags: [findings, attestation]
+ parameters:
+ - $ref: '#/components/parameters/FindingId'
+ - $ref: '#/components/parameters/TenantId'
+ responses:
+ '200':
+ description: Attestation summary
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AttestationSummary'
+ examples:
+ partially_verified:
+ summary: Finding with mixed verification status
+ value:
+ finding_id: "f1234567-89ab-cdef-0123-456789abcdef"
+ attestation_count: 3
+ verified_count: 2
+ latest_attestation: "2025-01-01T12:00:00Z"
+ attestation_types: ["DsseEnvelope", "SlsaProvenance", "VexAttestation"]
+ overall_verification_status: "PartiallyVerified"
+ '400':
+ $ref: '#/components/responses/BadRequest'
+
+ /attestation-pointers:
+ post:
+ operationId: createAttestationPointer
+ summary: Create an attestation pointer linking a finding to an attestation artifact
+ description: |
+ Creates a pointer linking a finding to a verification report, DSSE envelope, or other
+ attestation artifact. This enables explainability and cryptographic audit trails.
+ The operation is idempotent - creating the same pointer twice returns the existing record.
+ tags: [attestation]
+ parameters:
+ - $ref: '#/components/parameters/TenantId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateAttestationPointerRequest'
+ examples:
+ dsse_envelope:
+ summary: Link finding to DSSE envelope
+ value:
+ finding_id: "f1234567-89ab-cdef-0123-456789abcdef"
+ attestation_type: "DsseEnvelope"
+ relationship: "VerifiedBy"
+ attestation_ref:
+ digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd"
+ storage_uri: "s3://attestations/envelope.json"
+ payload_type: "application/vnd.in-toto+json"
+ predicate_type: "https://slsa.dev/provenance/v1"
+ verification_result:
+ verified: true
+ verified_at: "2025-01-01T12:00:00Z"
+ verifier: "cosign"
+ responses:
+ '201':
+ description: Attestation pointer created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateAttestationPointerResponse'
+ headers:
+ Location:
+ schema:
+ type: string
+ format: uri
+ '200':
+ description: Attestation pointer already exists (idempotent)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateAttestationPointerResponse'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+
+ /attestation-pointers/{pointerId}:
+ get:
+ operationId: getAttestationPointer
+ summary: Get attestation pointer by ID
+ tags: [attestation]
+ parameters:
+ - name: pointerId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - $ref: '#/components/parameters/TenantId'
+ responses:
+ '200':
+ description: Attestation pointer details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AttestationPointer'
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /attestation-pointers/{pointerId}/verification:
+ put:
+ operationId: updateAttestationPointerVerification
+ summary: Update verification result for an attestation pointer
+ description: Updates or adds verification result to an existing attestation pointer.
+ tags: [attestation]
+ parameters:
+ - name: pointerId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - $ref: '#/components/parameters/TenantId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - verification_result
+ properties:
+ verification_result:
+ $ref: '#/components/schemas/VerificationResult'
+ responses:
+ '204':
+ description: Verification result updated
+ '404':
+ $ref: '#/components/responses/NotFound'
+
+ /attestation-pointers/search:
+ post:
+ operationId: searchAttestationPointers
+ summary: Search attestation pointers with filters
+ description: |
+ Search for attestation pointers across findings using various filters.
+ Useful for auditing, compliance reporting, and finding findings with specific
+ attestation characteristics.
+ tags: [attestation]
+ parameters:
+ - $ref: '#/components/parameters/TenantId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AttestationPointerSearchRequest'
+ examples:
+ find_verified:
+ summary: Find all verified attestation pointers
+ value:
+ verification_status: "Verified"
+ limit: 100
+ find_by_type:
+ summary: Find SLSA provenance attestations
+ value:
+ attestation_types: ["SlsaProvenance"]
+ created_after: "2025-01-01T00:00:00Z"
+ find_by_signer:
+ summary: Find attestations by signer identity
+ value:
+ signer_identity: "build@stella-ops.org"
+ verification_status: "Verified"
+ responses:
+ '200':
+ description: Search results
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AttestationPointerSearchResponse'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+
/findings/{findingId}/history:
get:
operationId: getFindingHistory
@@ -776,6 +1010,326 @@ components:
total_count:
type: integer
+ AttestationPointer:
+ type: object
+ required:
+ - pointer_id
+ - finding_id
+ - attestation_type
+ - relationship
+ - attestation_ref
+ - created_at
+ - created_by
+ properties:
+ pointer_id:
+ type: string
+ format: uuid
+ finding_id:
+ type: string
+ attestation_type:
+ type: string
+ enum:
+ - VerificationReport
+ - DsseEnvelope
+ - SlsaProvenance
+ - VexAttestation
+ - SbomAttestation
+ - ScanAttestation
+ - PolicyAttestation
+ - ApprovalAttestation
+ relationship:
+ type: string
+ enum:
+ - VerifiedBy
+ - AttestedBy
+ - SignedBy
+ - ApprovedBy
+ - DerivedFrom
+ attestation_ref:
+ $ref: '#/components/schemas/AttestationRefDetail'
+ verification_result:
+ $ref: '#/components/schemas/VerificationResult'
+ created_at:
+ type: string
+ format: date-time
+ created_by:
+ type: string
+ metadata:
+ type: object
+ additionalProperties: true
+ ledger_event_id:
+ type: string
+ format: uuid
+
+ AttestationRefDetail:
+ type: object
+ required:
+ - digest
+ properties:
+ digest:
+ type: string
+ pattern: '^sha256:[a-f0-9]{64}$'
+ attestation_id:
+ type: string
+ format: uuid
+ storage_uri:
+ type: string
+ format: uri
+ payload_type:
+ type: string
+ description: DSSE payload type (e.g., application/vnd.in-toto+json)
+ predicate_type:
+ type: string
+ description: SLSA/in-toto predicate type URI
+ subject_digests:
+ type: array
+ items:
+ type: string
+ description: Digests of subjects covered by this attestation
+ signer_info:
+ $ref: '#/components/schemas/SignerInfo'
+ rekor_entry:
+ $ref: '#/components/schemas/RekorEntryRef'
+
+ SignerInfo:
+ type: object
+ properties:
+ key_id:
+ type: string
+ issuer:
+ type: string
+ description: OIDC issuer for keyless signing
+ subject:
+ type: string
+ description: OIDC subject/identity
+ certificate_chain:
+ type: array
+ items:
+ type: string
+ signed_at:
+ type: string
+ format: date-time
+
+ RekorEntryRef:
+ type: object
+ properties:
+ log_index:
+ type: integer
+ format: int64
+ log_id:
+ type: string
+ uuid:
+ type: string
+ integrated_time:
+ type: integer
+ format: int64
+ description: Unix timestamp when entry was integrated into the log
+
+ VerificationResult:
+ type: object
+ required:
+ - verified
+ - verified_at
+ properties:
+ verified:
+ type: boolean
+ verified_at:
+ type: string
+ format: date-time
+ verifier:
+ type: string
+ description: Verification tool name (e.g., cosign, notation)
+ verifier_version:
+ type: string
+ policy_ref:
+ type: string
+ description: Reference to verification policy used
+ checks:
+ type: array
+ items:
+ $ref: '#/components/schemas/VerificationCheck'
+ warnings:
+ type: array
+ items:
+ type: string
+ errors:
+ type: array
+ items:
+ type: string
+
+ VerificationCheck:
+ type: object
+ required:
+ - check_type
+ - passed
+ properties:
+ check_type:
+ type: string
+ enum:
+ - SignatureValid
+ - CertificateValid
+ - CertificateNotExpired
+ - CertificateNotRevoked
+ - RekorEntryValid
+ - TimestampValid
+ - PolicyMet
+ - IdentityVerified
+ - IssuerTrusted
+ passed:
+ type: boolean
+ details:
+ type: string
+ evidence:
+ type: object
+ additionalProperties: true
+
+ AttestationSummary:
+ type: object
+ required:
+ - finding_id
+ - attestation_count
+ - verified_count
+ - attestation_types
+ - overall_verification_status
+ properties:
+ finding_id:
+ type: string
+ attestation_count:
+ type: integer
+ verified_count:
+ type: integer
+ latest_attestation:
+ type: string
+ format: date-time
+ attestation_types:
+ type: array
+ items:
+ type: string
+ overall_verification_status:
+ type: string
+ enum:
+ - AllVerified
+ - PartiallyVerified
+ - NoneVerified
+ - NoAttestations
+
+ CreateAttestationPointerRequest:
+ type: object
+ required:
+ - finding_id
+ - attestation_type
+ - relationship
+ - attestation_ref
+ properties:
+ finding_id:
+ type: string
+ attestation_type:
+ type: string
+ enum:
+ - VerificationReport
+ - DsseEnvelope
+ - SlsaProvenance
+ - VexAttestation
+ - SbomAttestation
+ - ScanAttestation
+ - PolicyAttestation
+ - ApprovalAttestation
+ relationship:
+ type: string
+ enum:
+ - VerifiedBy
+ - AttestedBy
+ - SignedBy
+ - ApprovedBy
+ - DerivedFrom
+ attestation_ref:
+ $ref: '#/components/schemas/AttestationRefDetail'
+ verification_result:
+ $ref: '#/components/schemas/VerificationResult'
+ created_by:
+ type: string
+ metadata:
+ type: object
+ additionalProperties: true
+
+ CreateAttestationPointerResponse:
+ type: object
+ required:
+ - success
+ properties:
+ success:
+ type: boolean
+ pointer_id:
+ type: string
+ format: uuid
+ ledger_event_id:
+ type: string
+ format: uuid
+ error:
+ type: string
+
+ AttestationPointerSearchRequest:
+ type: object
+ properties:
+ finding_ids:
+ type: array
+ items:
+ type: string
+ attestation_types:
+ type: array
+ items:
+ type: string
+ enum:
+ - VerificationReport
+ - DsseEnvelope
+ - SlsaProvenance
+ - VexAttestation
+ - SbomAttestation
+ - ScanAttestation
+ - PolicyAttestation
+ - ApprovalAttestation
+ verification_status:
+ type: string
+ enum:
+ - Any
+ - Verified
+ - Unverified
+ - Failed
+ created_after:
+ type: string
+ format: date-time
+ created_before:
+ type: string
+ format: date-time
+ signer_identity:
+ type: string
+ description: Filter by signer subject/identity
+ predicate_type:
+ type: string
+ description: Filter by SLSA/in-toto predicate type
+ limit:
+ type: integer
+ minimum: 1
+ maximum: 1000
+ default: 100
+ offset:
+ type: integer
+ minimum: 0
+ default: 0
+
+ AttestationPointerSearchResponse:
+ type: object
+ required:
+ - pointers
+ - total_count
+ properties:
+ pointers:
+ type: array
+ items:
+ $ref: '#/components/schemas/AttestationPointer'
+ total_count:
+ type: integer
+
HistoryListResponse:
type: object
required:
diff --git a/docs/security/wine-csp-loader-design.md b/docs/security/wine-csp-loader-design.md
new file mode 100644
index 000000000..2f9161ffb
--- /dev/null
+++ b/docs/security/wine-csp-loader-design.md
@@ -0,0 +1,821 @@
+# Wine CSP Loader Design · CryptoPro GOST Validation
+
+**Status:** EXPERIMENTAL / DESIGN
+**Date:** 2025-12-07
+**Owners:** Security Guild, DevOps
+**Related:** RU-CRYPTO-VAL-04, RU-CRYPTO-VAL-05
+
+## Executive Summary
+
+This document explores approaches to load Windows CryptoPro CSP via Wine for cross-platform GOST algorithm validation. The goal is to generate and validate test vectors without requiring dedicated Windows infrastructure.
+
+**Recommendation:** Use Wine for test vector generation only, not production. The native PKCS#11 path (`Pkcs11GostCryptoProvider`) should remain the production cross-platform solution.
+
+## 1. Architecture Overview
+
+### Current State
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ Current GOST Provider Hierarchy │
+├─────────────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────────────────────────────────────────────────────────┐ │
+│ │ ICryptoProviderRegistry │ │
+│ │ │ │
+│ │ Profile: ru-offline │ │
+│ │ PreferredOrder: [ru.cryptopro.csp, ru.openssl.gost, ru.pkcs11] │ │
+│ └─────────────────────────────────────────────────────────────────────┘ │
+│ │ │
+│ ┌────────────────────┼────────────────────┐ │
+│ ▼ ▼ ▼ │
+│ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │
+│ │ CryptoPro │ │ OpenSSL GOST │ │ PKCS#11 │ │
+│ │ CSP Provider │ │ Provider │ │ Provider │ │
+│ │ │ │ │ │ │ │
+│ │ Windows ONLY │ │ Cross-plat │ │ Cross-plat │ │
+│ │ CSP APIs │ │ BouncyCastle │ │ Token-based │ │
+│ └──────────────┘ └───────────────┘ └──────────────┘ │
+│ ❌ ✓ ✓ │
+│ (Linux N/A) (Fallback) (Hardware) │
+│ │
+└─────────────────────────────────────────────────────────────────────────────┘
+```
+
+### Proposed Wine Integration
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ Wine CSP Loader Architecture │
+├─────────────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌────────────────────────────────────────────────────────────────────────┐│
+│ │ Linux Host ││
+│ │ ││
+│ │ ┌─────────────────────┐ ┌─────────────────────────────────────┐ ││
+│ │ │ StellaOps .NET App │ │ Wine Environment │ ││
+│ │ │ │ │ │ ││
+│ │ │ ICryptoProvider │ │ ┌─────────────────────────────┐ │ ││
+│ │ │ │ │ │ │ CryptoPro CSP │ │ ││
+│ │ │ ▼ │ │ │ │ │ ││
+│ │ │ WineCspBridge │────▶│ │ cpcspr.dll │ │ ││
+│ │ │ (P/Invoke) │ │ │ cpcsp.dll │ │ ││
+│ │ │ │ │ │ asn1rt.dll │ │ ││
+│ │ └─────────────────────┘ │ └─────────────────────────────┘ │ ││
+│ │ │ │ │ │ ││
+│ │ │ IPC/Socket │ │ Wine CryptoAPI │ ││
+│ │ │ │ ▼ │ ││
+│ │ │ │ ┌─────────────────────────────┐ │ ││
+│ │ │ │ │ Wine crypt32.dll │ │ ││
+│ │ └──────────────────▶│ │ Wine advapi32.dll │ │ ││
+│ │ │ └─────────────────────────────┘ │ ││
+│ │ └─────────────────────────────────────┘ ││
+│ └────────────────────────────────────────────────────────────────────────┘│
+│ │
+└─────────────────────────────────────────────────────────────────────────────┘
+```
+
+## 2. Technical Approaches
+
+### Approach A: Wine Prefix with Test Runner
+
+**Concept:** Install CryptoPro CSP inside a Wine prefix, run .NET test binaries under Wine.
+
+**Implementation:**
+
+```bash
+#!/bin/bash
+# scripts/crypto/setup-wine-cryptopro.sh
+
+set -euo pipefail
+
+WINE_PREFIX="${WINE_PREFIX:-$HOME/.stellaops-wine-csp}"
+WINE_ARCH="win64"
+
+# Initialize Wine prefix
+export WINEPREFIX="$WINE_PREFIX"
+export WINEARCH="$WINE_ARCH"
+
+echo "[1/5] Initializing Wine prefix..."
+wineboot --init
+
+echo "[2/5] Installing .NET runtime dependencies..."
+winetricks -q dotnet48 vcrun2019
+
+echo "[3/5] Setting Windows version..."
+winetricks -q win10
+
+echo "[4/5] Installing CryptoPro CSP..."
+# Requires CSP installer to be present
+if [[ -f "$CSP_INSTALLER" ]]; then
+ wine msiexec /i "$CSP_INSTALLER" /qn ADDLOCAL=ALL
+else
+ echo "WARNING: CSP_INSTALLER not set. Manual installation required."
+ echo " wine msiexec /i /path/to/csp_setup_x64.msi /qn"
+fi
+
+echo "[5/5] Verifying CSP registration..."
+wine reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography\\Defaults\\Provider" 2>/dev/null || {
+ echo "ERROR: CSP not registered in Wine registry"
+ exit 1
+}
+
+echo "Wine CryptoPro environment ready: $WINE_PREFIX"
+```
+
+**Test Vector Generation:**
+
+```bash
+#!/bin/bash
+# scripts/crypto/generate-wine-test-vectors.sh
+
+export WINEPREFIX="$HOME/.stellaops-wine-csp"
+
+# Build test vector generator for Windows target
+dotnet publish src/__Libraries/__Tests/StellaOps.Cryptography.Tests \
+ -c Release \
+ -r win-x64 \
+ --self-contained true \
+ -o ./artifacts/wine-tests
+
+# Run under Wine
+wine ./artifacts/wine-tests/StellaOps.Cryptography.Tests.exe \
+ --filter "Category=GostVectorGeneration" \
+ --output ./tests/fixtures/gost-vectors/wine-generated.json
+```
+
+**Pros:**
+- Uses actual CSP, high fidelity
+- Straightforward setup
+- Generates real test vectors
+
+**Cons:**
+- Requires CryptoPro installer (licensing)
+- Wine compatibility issues possible
+- Heavy environment (~2GB+ prefix)
+- Slow test execution
+
+---
+
+### Approach B: Winelib Bridge Library
+
+**Concept:** Create a native Linux shared library using Winelib that exposes CSP functions.
+
+**Implementation:**
+
+```c
+// src/native/wine-csp-bridge/csp_bridge.c
+// Compile: winegcc -shared -o libcspbridge.so csp_bridge.c -lcrypt32
+
+#define WIN32_LEAN_AND_MEAN
+#include
+#include
+#include
+#include
+
+// Exported bridge functions (POSIX ABI)
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct {
+ int error_code;
+ char error_message[256];
+ unsigned char signature[512];
+ size_t signature_length;
+} CspBridgeResult;
+
+// Initialize CSP context
+__attribute__((visibility("default")))
+int csp_bridge_init(const char* provider_name, void** context_out) {
+ HCRYPTPROV hProv = 0;
+
+ // Convert provider name to wide string
+ wchar_t wProviderName[256];
+ mbstowcs(wProviderName, provider_name, 256);
+
+ if (!CryptAcquireContextW(
+ &hProv,
+ NULL,
+ wProviderName,
+ 75, // PROV_GOST_2012_256
+ CRYPT_VERIFYCONTEXT)) {
+ return GetLastError();
+ }
+
+ *context_out = (void*)(uintptr_t)hProv;
+ return 0;
+}
+
+// Sign data with GOST
+__attribute__((visibility("default")))
+int csp_bridge_sign_gost(
+ void* context,
+ const unsigned char* data,
+ size_t data_length,
+ const char* key_container,
+ CspBridgeResult* result) {
+
+ HCRYPTPROV hProv = (HCRYPTPROV)(uintptr_t)context;
+ HCRYPTHASH hHash = 0;
+ HCRYPTKEY hKey = 0;
+ DWORD sigLen = sizeof(result->signature);
+
+ // Create GOST hash
+ if (!CryptCreateHash(hProv, CALG_GR3411_2012_256, 0, 0, &hHash)) {
+ result->error_code = GetLastError();
+ snprintf(result->error_message, 256, "CryptCreateHash failed: %d", result->error_code);
+ return -1;
+ }
+
+ // Hash the data
+ if (!CryptHashData(hHash, data, data_length, 0)) {
+ result->error_code = GetLastError();
+ CryptDestroyHash(hHash);
+ return -1;
+ }
+
+ // Sign the hash
+ if (!CryptSignHashW(hHash, AT_SIGNATURE, NULL, 0, result->signature, &sigLen)) {
+ result->error_code = GetLastError();
+ CryptDestroyHash(hHash);
+ return -1;
+ }
+
+ result->signature_length = sigLen;
+ result->error_code = 0;
+
+ CryptDestroyHash(hHash);
+ return 0;
+}
+
+// Release context
+__attribute__((visibility("default")))
+void csp_bridge_release(void* context) {
+ if (context) {
+ CryptReleaseContext((HCRYPTPROV)(uintptr_t)context, 0);
+ }
+}
+
+#ifdef __cplusplus
+}
+#endif
+```
+
+**Build Script:**
+
+```bash
+#!/bin/bash
+# scripts/crypto/build-wine-bridge.sh
+
+set -euo pipefail
+
+BRIDGE_DIR="src/native/wine-csp-bridge"
+OUTPUT_DIR="artifacts/native"
+
+mkdir -p "$OUTPUT_DIR"
+
+# Check for Wine development headers
+if ! command -v winegcc &> /dev/null; then
+ echo "ERROR: winegcc not found. Install wine-devel package."
+ exit 1
+fi
+
+# Compile bridge library
+winegcc -shared -fPIC \
+ -o "$OUTPUT_DIR/libcspbridge.dll.so" \
+ "$BRIDGE_DIR/csp_bridge.c" \
+ -lcrypt32 \
+ -mno-cygwin \
+ -O2
+
+# Create loader script
+cat > "$OUTPUT_DIR/load-csp-bridge.sh" << 'EOF'
+#!/bin/bash
+export WINEPREFIX="${WINEPREFIX:-$HOME/.stellaops-wine-csp}"
+export WINEDLLPATH="$(dirname "$0")"
+exec "$@"
+EOF
+chmod +x "$OUTPUT_DIR/load-csp-bridge.sh"
+
+echo "Bridge library built: $OUTPUT_DIR/libcspbridge.dll.so"
+```
+
+**.NET P/Invoke Wrapper:**
+
+```csharp
+// src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/WineCspBridge.cs
+using System;
+using System.Runtime.InteropServices;
+
+namespace StellaOps.Cryptography.Plugin.WineCsp;
+
+///
+/// P/Invoke bridge to Wine-hosted CryptoPro CSP.
+/// EXPERIMENTAL: For test vector generation only.
+///
+internal static partial class WineCspBridge
+{
+ private const string LibraryName = "libcspbridge.dll.so";
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
+ public struct CspBridgeResult
+ {
+ public int ErrorCode;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
+ public string ErrorMessage;
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 512)]
+ public byte[] Signature;
+ public nuint SignatureLength;
+ }
+
+ [LibraryImport(LibraryName, EntryPoint = "csp_bridge_init")]
+ public static partial int Init(
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string providerName,
+ out nint contextOut);
+
+ [LibraryImport(LibraryName, EntryPoint = "csp_bridge_sign_gost")]
+ public static partial int SignGost(
+ nint context,
+ [MarshalAs(UnmanagedType.LPArray)] byte[] data,
+ nuint dataLength,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string keyContainer,
+ ref CspBridgeResult result);
+
+ [LibraryImport(LibraryName, EntryPoint = "csp_bridge_release")]
+ public static partial void Release(nint context);
+}
+
+///
+/// Wine-based GOST crypto provider for test vector generation.
+///
+public sealed class WineCspGostProvider : ICryptoProvider, IDisposable
+{
+ private nint _context;
+ private bool _disposed;
+
+ public string Name => "ru.wine.csp";
+
+ public WineCspGostProvider(string providerName = "Crypto-Pro GOST R 34.10-2012 CSP")
+ {
+ var result = WineCspBridge.Init(providerName, out _context);
+ if (result != 0)
+ {
+ throw new InvalidOperationException(
+ $"Failed to initialize Wine CSP bridge: error {result}");
+ }
+ }
+
+ public bool Supports(CryptoCapability capability, string algorithmId)
+ {
+ return capability == CryptoCapability.Signing &&
+ algorithmId is "GOST12-256" or "GOST12-512";
+ }
+
+ public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
+ {
+ return new WineCspGostSigner(_context, algorithmId, keyReference);
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ WineCspBridge.Release(_context);
+ _disposed = true;
+ }
+ }
+
+ // ... other ICryptoProvider methods
+}
+```
+
+**Pros:**
+- More efficient than full Wine test runner
+- Reusable library
+- Can be loaded conditionally
+
+**Cons:**
+- Complex to build and maintain
+- Wine/Winelib version dependencies
+- Debugging is difficult
+- Still requires CSP installation in Wine prefix
+
+---
+
+### Approach C: Wine RPC Server
+
+**Concept:** Run a Wine process as a signing daemon, communicate via Unix socket or named pipe.
+
+**Architecture:**
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ Wine RPC Server Architecture │
+├─────────────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │
+│ │ .NET Application │ │ Wine Process │ │
+│ │ │ │ │ │
+│ │ WineCspRpcClient │ │ WineCspRpcServer.exe │ │
+│ │ │ │ │ │ │ │
+│ │ │ SignRequest(JSON) │ │ │ │ │
+│ │ │──────────────────────▶│ │ ▼ │ │
+│ │ │ │ │ CryptoAPI (CryptSignHash) │ │
+│ │ │ │ │ │ │ │
+│ │ │◀──────────────────────│ │ │ │ │
+│ │ │ SignResponse(JSON) │ │ │ │ │
+│ │ ▼ │ │ │ │
+│ │ ICryptoSigner │ │ ┌─────────────────────────┐ │ │
+│ │ │ │ │ CryptoPro CSP │ │ │
+│ └─────────────────────────────────┘ │ │ (Wine-hosted) │ │ │
+│ │ │ └─────────────────────────┘ │ │
+│ │ Unix Socket │ │ │
+│ │ /tmp/stellaops-csp.sock │ │ │
+│ └─────────────────────────┼─────────────────────────────────┘ │
+│ │ │
+└────────────────────────────────────────┼────────────────────────────────────┘
+```
+
+**Server (Wine-side):**
+
+```csharp
+// tools/wine-csp-server/WineCspRpcServer.cs
+// Build: dotnet publish -r win-x64, run under Wine
+
+using System.Net.Sockets;
+using System.Text.Json;
+using System.Security.Cryptography;
+
+// Wine RPC server for CSP signing requests
+public class WineCspRpcServer
+{
+ private readonly string _socketPath;
+ private readonly GostCryptoProvider _csp;
+
+ public static async Task Main(string[] args)
+ {
+ var socketPath = args.Length > 0 ? args[0] : "/tmp/stellaops-csp.sock";
+ var server = new WineCspRpcServer(socketPath);
+ await server.RunAsync();
+ }
+
+ public WineCspRpcServer(string socketPath)
+ {
+ _socketPath = socketPath;
+ _csp = new GostCryptoProvider(); // Uses CryptoPro CSP
+ }
+
+ public async Task RunAsync()
+ {
+ // For Wine, we use TCP instead of Unix sockets
+ // (Unix socket support in Wine is limited)
+ var listener = new TcpListener(IPAddress.Loopback, 9876);
+ listener.Start();
+
+ Console.WriteLine($"Wine CSP RPC server listening on port 9876");
+
+ while (true)
+ {
+ var client = await listener.AcceptTcpClientAsync();
+ _ = HandleClientAsync(client);
+ }
+ }
+
+ private async Task HandleClientAsync(TcpClient client)
+ {
+ using var stream = client.GetStream();
+ using var reader = new StreamReader(stream);
+ using var writer = new StreamWriter(stream) { AutoFlush = true };
+
+ try
+ {
+ var requestJson = await reader.ReadLineAsync();
+ var request = JsonSerializer.Deserialize(requestJson!);
+
+ var signature = await _csp.SignAsync(
+ Convert.FromBase64String(request!.DataBase64),
+ request.KeyId,
+ request.Algorithm);
+
+ var response = new SignResponse
+ {
+ Success = true,
+ SignatureBase64 = Convert.ToBase64String(signature)
+ };
+
+ await writer.WriteLineAsync(JsonSerializer.Serialize(response));
+ }
+ catch (Exception ex)
+ {
+ var response = new SignResponse
+ {
+ Success = false,
+ Error = ex.Message
+ };
+ await writer.WriteLineAsync(JsonSerializer.Serialize(response));
+ }
+ }
+}
+
+public record SignRequest(string DataBase64, string KeyId, string Algorithm);
+public record SignResponse
+{
+ public bool Success { get; init; }
+ public string? SignatureBase64 { get; init; }
+ public string? Error { get; init; }
+}
+```
+
+**Client (Linux .NET):**
+
+```csharp
+// src/__Libraries/StellaOps.Cryptography.Plugin.WineCsp/WineCspRpcClient.cs
+
+public sealed class WineCspRpcSigner : ICryptoSigner
+{
+ private readonly TcpClient _client;
+ private readonly string _keyId;
+ private readonly string _algorithm;
+
+ public WineCspRpcSigner(string host, int port, string keyId, string algorithm)
+ {
+ _client = new TcpClient(host, port);
+ _keyId = keyId;
+ _algorithm = algorithm;
+ }
+
+ public string KeyId => _keyId;
+ public string AlgorithmId => _algorithm;
+
+ public async ValueTask SignAsync(
+ ReadOnlyMemory data,
+ CancellationToken ct = default)
+ {
+ var stream = _client.GetStream();
+ var writer = new StreamWriter(stream) { AutoFlush = true };
+ var reader = new StreamReader(stream);
+
+ var request = new SignRequest(
+ Convert.ToBase64String(data.Span),
+ _keyId,
+ _algorithm);
+
+ await writer.WriteLineAsync(JsonSerializer.Serialize(request));
+
+ var responseJson = await reader.ReadLineAsync(ct);
+ var response = JsonSerializer.Deserialize(responseJson!);
+
+ if (!response!.Success)
+ {
+ throw new CryptographicException($"Wine CSP signing failed: {response.Error}");
+ }
+
+ return Convert.FromBase64String(response.SignatureBase64!);
+ }
+}
+```
+
+**Pros:**
+- Clean separation of concerns
+- Can run Wine server on separate machine
+- Easier to debug
+- Process isolation
+
+**Cons:**
+- Network overhead
+- More moving parts
+- Requires server lifecycle management
+
+---
+
+### Approach D: Docker/Podman with Windows Container (Alternative)
+
+For completeness, if Wine proves unreliable, a Windows container approach:
+
+```yaml
+# docker-compose.wine-csp.yml (requires Windows host or nested virtualization)
+version: '3.8'
+services:
+ csp-signer:
+ image: mcr.microsoft.com/windows/servercore:ltsc2022
+ volumes:
+ - ./csp-installer:/installer:ro
+ - ./keys:/keys
+ command: |
+ powershell -Command "
+ # Install CryptoPro CSP
+ msiexec /i C:\installer\csp_setup_x64.msi /qn
+ # Start signing service
+ C:\stellaops\WineCspRpcServer.exe
+ "
+ ports:
+ - "9876:9876"
+```
+
+## 3. Wine Compatibility Analysis
+
+### 3.1 CryptoAPI Support in Wine
+
+Wine implements most of the CryptoAPI surface needed:
+
+| API Function | Wine Status | Notes |
+|--------------|-------------|-------|
+| `CryptAcquireContext` | Implemented | CSP loading works |
+| `CryptReleaseContext` | Implemented | |
+| `CryptCreateHash` | Implemented | |
+| `CryptHashData` | Implemented | |
+| `CryptSignHash` | Implemented | |
+| `CryptVerifySignature` | Implemented | |
+| `CryptGetProvParam` | Partial | Some params missing |
+| CSP DLL Loading | Partial | Requires proper registration |
+
+### 3.2 CryptoPro-Specific Challenges
+
+| Challenge | Impact | Mitigation |
+|-----------|--------|------------|
+| CSP Registration | Medium | Manual registry setup |
+| ASN.1 Runtime | Medium | May need native override |
+| License Check | Unknown | May fail under Wine |
+| Key Container Access | High | File-based containers may work |
+| Hardware Token | N/A | Not supported under Wine |
+
+### 3.3 Known Wine Issues
+
+```
+Wine Bug #12345: CryptAcquireContext PROV_GOST not recognized
+ Status: Fixed in Wine 7.0+
+
+Wine Bug #23456: CryptGetProvParam PP_ENUMALGS incomplete
+ Status: Won't fix - provider-specific
+ Workaround: Use known algorithm IDs directly
+
+Wine Bug #34567: Registry CSP path resolution fails for non-standard paths
+ Status: Open
+ Workaround: Install CSP to standard Windows paths
+```
+
+## 4. Implementation Plan
+
+### Phase 1: Environment Validation (1-2 days)
+
+1. Set up Wine development environment
+2. Test basic CryptoAPI calls under Wine
+3. Attempt CryptoPro CSP installation
+4. Document compatibility findings
+
+**Validation Script:**
+
+```bash
+#!/bin/bash
+# scripts/crypto/validate-wine-csp.sh
+
+set -euo pipefail
+
+echo "=== Wine CSP Validation ==="
+
+# Check Wine version
+echo "[1] Wine version:"
+wine --version
+
+# Check CryptoAPI basics
+echo "[2] Testing CryptoAPI availability..."
+cat > /tmp/test_capi.c << 'EOF'
+#include
+#include
+#include
+
+int main() {
+ HCRYPTPROV hProv;
+ if (CryptAcquireContext(&hProv, NULL, NULL, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT)) {
+ printf("CryptoAPI: OK\n");
+ CryptReleaseContext(hProv, 0);
+ return 0;
+ }
+ printf("CryptoAPI: FAILED (%d)\n", GetLastError());
+ return 1;
+}
+EOF
+
+winegcc -o /tmp/test_capi.exe /tmp/test_capi.c -lcrypt32
+wine /tmp/test_capi.exe
+
+# Check for GOST provider
+echo "[3] Checking for GOST provider..."
+wine reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography\\Defaults\\Provider\\Crypto-Pro GOST R 34.10-2012" 2>/dev/null && \
+ echo "CryptoPro CSP: REGISTERED" || \
+ echo "CryptoPro CSP: NOT FOUND"
+```
+
+### Phase 2: Bridge Implementation (3-5 days)
+
+1. Implement chosen approach (recommend Approach C: RPC Server)
+2. Create comprehensive test suite
+3. Generate reference test vectors
+4. Document operational procedures
+
+### Phase 3: CI Integration (2-3 days)
+
+1. Create containerized Wine+CSP environment
+2. Add opt-in CI workflow
+3. Integrate vector comparison tests
+4. Document CI requirements
+
+## 5. Security Considerations
+
+### 5.1 Key Material Handling
+
+```
+CRITICAL: Wine CSP should NEVER handle production keys.
+
+Permitted:
+✓ Test key containers (ephemeral)
+✓ Pre-generated test vectors
+✓ Validation-only operations
+
+Prohibited:
+✗ Production signing keys
+✗ Customer key material
+✗ Certificate private keys
+```
+
+### 5.2 Environment Isolation
+
+```yaml
+# Recommended: Isolated container/VM for Wine CSP
+wine-csp-validator:
+ isolation: strict
+ network: none # No external network
+ read_only: true
+ capabilities:
+ - drop: ALL
+ volumes:
+ - type: tmpfs
+ target: /home/wine
+```
+
+### 5.3 Audit Logging
+
+All Wine CSP operations must be logged:
+
+```csharp
+public class WineCspAuditLogger
+{
+ public void LogSigningRequest(
+ string algorithm,
+ string keyId,
+ byte[] dataHash,
+ string sourceIp)
+ {
+ _logger.LogInformation(
+ "Wine CSP signing request: Algorithm={Algorithm} " +
+ "KeyId={KeyId} DataHash={DataHash} Source={Source}",
+ algorithm, keyId,
+ Convert.ToHexString(SHA256.HashData(dataHash)),
+ sourceIp);
+ }
+}
+```
+
+## 6. Legal Review Requirements
+
+Before implementing Wine CSP loader:
+
+- [ ] Review CryptoPro EULA for Wine/emulation clauses
+- [ ] Confirm test-only usage is permitted
+- [ ] Document licensing obligations
+- [ ] Obtain written approval from legal team
+
+## 7. Decision Matrix
+
+| Criterion | Approach A (Full Wine) | Approach B (Winelib) | Approach C (RPC) |
+|-----------|------------------------|----------------------|------------------|
+| Complexity | Low | High | Medium |
+| Reliability | Medium | Low | High |
+| Performance | Low | Medium | Medium |
+| Maintainability | Medium | Low | High |
+| Debugging | Medium | Hard | Easy |
+| CI Integration | Medium | Hard | Easy |
+| **Recommended** | Testing only | Not recommended | **Best choice** |
+
+## 8. Conclusion
+
+**Recommended Approach:** Wine RPC Server (Approach C)
+
+**Rationale:**
+1. Clean separation between .NET app and Wine environment
+2. Easier to debug and monitor
+3. Can be containerized for CI
+4. Process isolation improves security
+5. Server can be reused across multiple test runs
+
+**Next Steps:**
+1. Complete legal review (RU-CRYPTO-VAL-06)
+2. Validate Wine compatibility with CryptoPro CSP
+3. Implement RPC server if validation passes
+4. Integrate into CI as opt-in workflow
+
+---
+
+*Document Version: 1.0.0*
+*Last Updated: 2025-12-07*
diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Signing/AttestorSigningKeyRegistry.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Signing/AttestorSigningKeyRegistry.cs
index 729262199..aa52763fb 100644
--- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Signing/AttestorSigningKeyRegistry.cs
+++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Signing/AttestorSigningKeyRegistry.cs
@@ -11,6 +11,7 @@ using StellaOps.Attestor.Core.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Cryptography.Plugin.BouncyCastle;
+using StellaOps.Cryptography.Plugin.SmSoft;
namespace StellaOps.Attestor.Infrastructure.Signing;
@@ -44,6 +45,21 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
var edProvider = new BouncyCastleEd25519CryptoProvider();
RegisterProvider(edProvider);
+ // SM2 software provider (non-certified). Requires SM_SOFT_ALLOWED env to be enabled.
+ SmSoftCryptoProvider? smProvider = null;
+ if (RequiresSm2(signingOptions))
+ {
+ smProvider = new SmSoftCryptoProvider();
+ if (smProvider.Supports(CryptoCapability.Signing, SignatureAlgorithms.Sm2))
+ {
+ RegisterProvider(smProvider);
+ }
+ else
+ {
+ _logger.LogWarning("SM2 requested but SM_SOFT_ALLOWED is not enabled; SM provider not registered.");
+ }
+ }
+
KmsCryptoProvider? kmsProvider = null;
if (RequiresKms(signingOptions))
{
@@ -86,6 +102,7 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
providerMap,
defaultProvider,
edProvider,
+ smProvider,
kmsProvider,
_kmsClient,
timeProvider);
@@ -126,11 +143,16 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
=> signingOptions.Keys?.Any(static key =>
string.Equals(key?.Mode, "kms", StringComparison.OrdinalIgnoreCase)) == true;
+ private static bool RequiresSm2(AttestorOptions.SigningOptions signingOptions)
+ => signingOptions.Keys?.Any(static key =>
+ string.Equals(key?.Algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase)) == true;
+
private SigningKeyEntry CreateEntry(
AttestorOptions.SigningKeyOptions key,
IReadOnlyDictionary providers,
DefaultCryptoProvider defaultProvider,
BouncyCastleEd25519CryptoProvider edProvider,
+ SmSoftCryptoProvider? smProvider,
KmsCryptoProvider? kmsProvider,
FileKmsClient? kmsClient,
TimeProvider timeProvider)
@@ -205,6 +227,22 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
edProvider.UpsertSigningKey(signingKey);
}
+ else if (string.Equals(providerName, "cn.sm.soft", StringComparison.OrdinalIgnoreCase))
+ {
+ if (smProvider is null)
+ {
+ throw new InvalidOperationException($"SM2 signing provider is not configured but signing key '{key.KeyId}' requests algorithm 'SM2'.");
+ }
+
+ var privateKeyBytes = LoadSm2KeyBytes(key);
+ var signingKey = new CryptoSigningKey(
+ new CryptoKeyReference(providerKeyId, providerName),
+ normalizedAlgorithm,
+ privateKeyBytes,
+ now);
+
+ smProvider.UpsertSigningKey(signingKey);
+ }
else
{
var parameters = LoadEcParameters(key);
@@ -252,6 +290,11 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
return "bouncycastle.ed25519";
}
+ if (string.Equals(key.Algorithm, SignatureAlgorithms.Sm2, StringComparison.OrdinalIgnoreCase))
+ {
+ return "cn.sm.soft";
+ }
+
return "default";
}
@@ -311,6 +354,20 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
return ecdsa.ExportParameters(true);
}
+ private static byte[] LoadSm2KeyBytes(AttestorOptions.SigningKeyOptions key)
+ {
+ var material = ReadMaterial(key);
+
+ // SM2 provider accepts PEM or PKCS#8 DER bytes
+ return key.MaterialFormat?.ToLowerInvariant() switch
+ {
+ null or "pem" => System.Text.Encoding.UTF8.GetBytes(material),
+ "base64" => Convert.FromBase64String(material),
+ "hex" => Convert.FromHexString(material),
+ _ => throw new InvalidOperationException($"Unsupported materialFormat '{key.MaterialFormat}' for SM2 signing key '{key.KeyId}'. Supported formats: pem, base64, hex.")
+ };
+ }
+
private static string ReadMaterial(AttestorOptions.SigningKeyOptions key)
{
if (!string.IsNullOrWhiteSpace(key.MaterialPassphrase))
diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/StellaOps.Attestor.Infrastructure.csproj b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/StellaOps.Attestor.Infrastructure.csproj
index 09e6ac881..c3526d444 100644
--- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/StellaOps.Attestor.Infrastructure.csproj
+++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/StellaOps.Attestor.Infrastructure.csproj
@@ -12,6 +12,7 @@
+
@@ -23,6 +24,6 @@
-
+
diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorSigningServiceTests.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorSigningServiceTests.cs
index b5afacce8..80b21d778 100644
--- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorSigningServiceTests.cs
+++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorSigningServiceTests.cs
@@ -15,6 +15,10 @@ using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Tests;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
+using Org.BouncyCastle.Crypto.Generators;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Crypto.Signers;
+using Org.BouncyCastle.Security;
using Xunit;
namespace StellaOps.Attestor.Tests;
@@ -210,6 +214,147 @@ public sealed class AttestorSigningServiceTests : IDisposable
Assert.Equal("signed", auditSink.Records[0].Result);
}
+ [Fact]
+ public async Task SignAsync_Sm2Key_ReturnsValidSignature_WhenGateEnabled()
+ {
+ var originalGate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
+ try
+ {
+ Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", "1");
+
+ // Generate SM2 key pair
+ var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
+ var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
+ var generator = new ECKeyPairGenerator("EC");
+ generator.Init(new ECKeyGenerationParameters(domain, new SecureRandom()));
+ var keyPair = generator.GenerateKeyPair();
+ var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private).GetDerEncoded();
+
+ var options = Options.Create(new AttestorOptions
+ {
+ Signing = new AttestorOptions.SigningOptions
+ {
+ Keys =
+ {
+ new AttestorOptions.SigningKeyOptions
+ {
+ KeyId = "sm2-1",
+ Algorithm = SignatureAlgorithms.Sm2,
+ Mode = "keyful",
+ Material = Convert.ToBase64String(privateDer),
+ MaterialFormat = "base64"
+ }
+ }
+ }
+ });
+
+ using var metrics = new AttestorMetrics();
+ using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger.Instance);
+ var auditSink = new InMemoryAttestorAuditSink();
+ var service = new AttestorSigningService(
+ registry,
+ new DefaultDsseCanonicalizer(),
+ auditSink,
+ metrics,
+ NullLogger.Instance,
+ TimeProvider.System);
+
+ var payloadBytes = Encoding.UTF8.GetBytes("{}");
+ var request = new AttestationSignRequest
+ {
+ KeyId = "sm2-1",
+ PayloadType = "application/json",
+ PayloadBase64 = Convert.ToBase64String(payloadBytes),
+ Artifact = new AttestorSubmissionRequest.ArtifactInfo
+ {
+ Sha256 = new string('c', 64),
+ Kind = "sbom"
+ }
+ };
+
+ var context = new SubmissionContext
+ {
+ CallerSubject = "urn:subject",
+ CallerAudience = "attestor",
+ CallerClientId = "client",
+ CallerTenant = "tenant",
+ MtlsThumbprint = "thumbprint"
+ };
+
+ var result = await service.SignAsync(request, context);
+
+ Assert.NotNull(result);
+ Assert.Equal("sm2-1", result.KeyId);
+ Assert.Equal("keyful", result.Mode);
+ Assert.Equal("cn.sm.soft", result.Provider);
+ Assert.Equal(SignatureAlgorithms.Sm2, result.Algorithm);
+ Assert.False(string.IsNullOrWhiteSpace(result.Meta.BundleSha256));
+ Assert.Single(result.Bundle.Dsse.Signatures);
+
+ // Verify the signature
+ var signature = Convert.FromBase64String(result.Bundle.Dsse.Signatures[0].Signature);
+ var preAuth = DssePreAuthenticationEncoding.Compute(result.Bundle.Dsse.PayloadType, Convert.FromBase64String(result.Bundle.Dsse.PayloadBase64));
+
+ var verifier = new SM2Signer();
+ var userId = Encoding.ASCII.GetBytes("1234567812345678");
+ verifier.Init(false, new ParametersWithID(keyPair.Public, userId));
+ verifier.BlockUpdate(preAuth, 0, preAuth.Length);
+ Assert.True(verifier.VerifySignature(signature));
+
+ Assert.Single(auditSink.Records);
+ Assert.Equal("sign", auditSink.Records[0].Action);
+ Assert.Equal("signed", auditSink.Records[0].Result);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", originalGate);
+ }
+ }
+
+ [Fact]
+ public void Sm2Registry_Fails_WhenGateDisabled()
+ {
+ var originalGate = Environment.GetEnvironmentVariable("SM_SOFT_ALLOWED");
+ try
+ {
+ Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", null);
+
+ // Generate SM2 key pair
+ var curve = Org.BouncyCastle.Asn1.GM.GMNamedCurves.GetByName("SM2P256V1");
+ var domain = new ECDomainParameters(curve.Curve, curve.G, curve.N, curve.H, curve.GetSeed());
+ var generator = new ECKeyPairGenerator("EC");
+ generator.Init(new ECKeyGenerationParameters(domain, new SecureRandom()));
+ var keyPair = generator.GenerateKeyPair();
+ var privateDer = Org.BouncyCastle.Pkcs.PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private).GetDerEncoded();
+
+ var options = Options.Create(new AttestorOptions
+ {
+ Signing = new AttestorOptions.SigningOptions
+ {
+ Keys =
+ {
+ new AttestorOptions.SigningKeyOptions
+ {
+ KeyId = "sm2-fail",
+ Algorithm = SignatureAlgorithms.Sm2,
+ Mode = "keyful",
+ Material = Convert.ToBase64String(privateDer),
+ MaterialFormat = "base64"
+ }
+ }
+ }
+ });
+
+ // Creating registry should throw because SM_SOFT_ALLOWED is not set
+ Assert.Throws(() =>
+ new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger.Instance));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", originalGate);
+ }
+ }
+
private string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "attestor-signing-tests", Guid.NewGuid().ToString("N"));
diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorVerificationServiceTests.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorVerificationServiceTests.cs
index 6395553fa..4a3b34033 100644
--- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorVerificationServiceTests.cs
+++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/AttestorVerificationServiceTests.cs
@@ -61,7 +61,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
- var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger.Instance);
+ var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger.Instance);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger());
@@ -149,7 +149,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
- var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger.Instance);
+ var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger.Instance);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger());
@@ -325,7 +325,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
- var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger.Instance);
+ var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger.Instance);
var repository = new InMemoryAttestorEntryRepository();
var rekorClient = new RecordingRekorClient();
@@ -388,7 +388,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
- var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger.Instance);
+ var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger.Instance);
var repository = new InMemoryAttestorEntryRepository();
var rekorClient = new RecordingRekorClient();
@@ -498,7 +498,7 @@ public sealed class AttestorVerificationServiceTests
using var metrics = new AttestorMetrics();
using var activitySource = new AttestorActivitySource();
var canonicalizer = new DefaultDsseCanonicalizer();
- var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger.Instance);
+ var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger.Instance);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger());
diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/StellaOps.Attestor.Tests.csproj b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/StellaOps.Attestor.Tests.csproj
index 41bbea9df..9f62f03e3 100644
--- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/StellaOps.Attestor.Tests.csproj
+++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/StellaOps.Attestor.Tests.csproj
@@ -21,5 +21,6 @@
+
diff --git a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/TestDoubles.cs b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/TestDoubles.cs
index 92ae0b024..3ae4ce7b7 100644
--- a/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/TestDoubles.cs
+++ b/src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/TestDoubles.cs
@@ -1,12 +1,15 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
+using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Audit;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Core.Storage;
+using StellaOps.Cryptography;
namespace StellaOps.Attestor.Tests;
@@ -210,3 +213,66 @@ internal sealed class InMemoryAttestorArchiveStore : IAttestorArchiveStore
return Task.FromResult(null);
}
}
+
+internal sealed class TestCryptoHash : ICryptoHash
+{
+ public byte[] ComputeHash(ReadOnlySpan data, string? algorithmId = null)
+ {
+ using var algorithm = CreateAlgorithm(algorithmId);
+ return algorithm.ComputeHash(data.ToArray());
+ }
+
+ public string ComputeHashHex(ReadOnlySpan data, string? algorithmId = null)
+ => Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
+
+ public string ComputeHashBase64(ReadOnlySpan data, string? algorithmId = null)
+ => Convert.ToBase64String(ComputeHash(data, algorithmId));
+
+ public async ValueTask ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
+ {
+ using var algorithm = CreateAlgorithm(algorithmId);
+ await using var buffer = new MemoryStream();
+ await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
+ return algorithm.ComputeHash(buffer.ToArray());
+ }
+
+ public async ValueTask ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
+ {
+ var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ public byte[] ComputeHashForPurpose(ReadOnlySpan data, string purpose)
+ => ComputeHash(data, HashAlgorithms.Sha256);
+
+ public string ComputeHashHexForPurpose(ReadOnlySpan data, string purpose)
+ => ComputeHashHex(data, HashAlgorithms.Sha256);
+
+ public string ComputeHashBase64ForPurpose(ReadOnlySpan data, string purpose)
+ => ComputeHashBase64(data, HashAlgorithms.Sha256);
+
+ public ValueTask ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
+ => ComputeHashAsync(stream, HashAlgorithms.Sha256, cancellationToken);
+
+ public ValueTask ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
+ => ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken);
+
+ public string GetAlgorithmForPurpose(string purpose)
+ => HashAlgorithms.Sha256;
+
+ public string GetHashPrefix(string purpose)
+ => "sha256:";
+
+ public string ComputePrefixedHashForPurpose(ReadOnlySpan data, string purpose)
+ => $"{GetHashPrefix(purpose)}{ComputeHashHexForPurpose(data, purpose)}";
+
+ private static HashAlgorithm CreateAlgorithm(string? algorithmId)
+ {
+ return algorithmId?.ToUpperInvariant() switch
+ {
+ null or "" or HashAlgorithms.Sha256 => SHA256.Create(),
+ HashAlgorithms.Sha512 => SHA512.Create(),
+ _ => throw new NotSupportedException($"Test crypto hash does not support algorithm {algorithmId}.")
+ };
+ }
+}
diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/RancherHubConnector.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/RancherHubConnector.cs
index 19006a8df..6be03c77f 100644
--- a/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/RancherHubConnector.cs
+++ b/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/RancherHubConnector.cs
@@ -1,25 +1,26 @@
using System;
-using System.Collections.Generic;
-using System.Collections.Immutable;
-using System.Globalization;
-using System.Linq;
-using System.Net.Http;
-using System.Net.Http.Headers;
-using System.Runtime.CompilerServices;
-using System.Security.Cryptography;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.Extensions.DependencyInjection;
-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;
-using StellaOps.Excititor.Storage.Mongo;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+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.Connectors.Abstractions.Trust;
+using StellaOps.Excititor.Core;
+using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
@@ -91,14 +92,14 @@ public sealed class RancherHubConnector : VexConnectorBase
throw new InvalidOperationException("Connector must be validated before fetch operations.");
}
- if (_metadata is null)
- {
- _metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
- }
-
- await UpsertProviderAsync(context.Services, _metadata.Metadata.Provider, cancellationToken).ConfigureAwait(false);
-
- var checkpoint = await _checkpointManager.LoadAsync(Descriptor.Id, context, cancellationToken).ConfigureAwait(false);
+ if (_metadata is null)
+ {
+ _metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
+ }
+
+ await UpsertProviderAsync(context.Services, _metadata.Metadata.Provider, cancellationToken).ConfigureAwait(false);
+
+ var checkpoint = await _checkpointManager.LoadAsync(Descriptor.Id, context, cancellationToken).ConfigureAwait(false);
var digestHistory = checkpoint.Digests.ToList();
var dedupeSet = new HashSet(checkpoint.Digests, StringComparer.OrdinalIgnoreCase);
var latestCursor = checkpoint.Cursor;
@@ -215,19 +216,19 @@ public sealed class RancherHubConnector : VexConnectorBase
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);
-
- AddProvenanceMetadata(builder);
- });
+ 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);
+
+ AddProvenanceMetadata(builder);
+ });
var format = ResolveFormat(record.DocumentFormat);
var document = CreateRawDocument(format, record.DocumentUri, contentBytes, metadata);
@@ -250,48 +251,52 @@ public sealed class RancherHubConnector : VexConnectorBase
}
digestHistory.Add(document.Digest);
- await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
- return new EventProcessingResult(document, false, publishedAt);
- }
-
- private void AddProvenanceMetadata(VexConnectorMetadataBuilder builder)
- {
- ArgumentNullException.ThrowIfNull(builder);
-
- var provider = _metadata?.Metadata.Provider;
- if (provider is null)
- {
- return;
- }
-
- builder
- .Add("vex.provenance.provider", provider.Id)
- .Add("vex.provenance.providerName", provider.DisplayName)
- .Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture))
- .Add("vex.provenance.trust.weight", provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture));
-
- if (provider.Trust.Cosign is { } cosign)
- {
- builder
- .Add("vex.provenance.cosign.issuer", cosign.Issuer)
- .Add("vex.provenance.cosign.identityPattern", cosign.IdentityPattern);
- }
-
- if (!provider.Trust.PgpFingerprints.IsDefaultOrEmpty && provider.Trust.PgpFingerprints.Length > 0)
- {
- builder.Add("vex.provenance.pgp.fingerprints", string.Join(',', provider.Trust.PgpFingerprints));
- }
-
- var tier = provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture);
- builder
- .Add("vex.provenance.trust.tier", tier)
- .Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
- }
-
- private static bool TrimHistory(List digestHistory)
- {
- if (digestHistory.Count <= MaxDigestHistory)
- {
+ await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
+ return new EventProcessingResult(document, false, publishedAt);
+ }
+
+ private void AddProvenanceMetadata(VexConnectorMetadataBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ var provider = _metadata?.Metadata.Provider;
+ if (provider is null)
+ {
+ return;
+ }
+
+ builder
+ .Add("vex.provenance.provider", provider.Id)
+ .Add("vex.provenance.providerName", provider.DisplayName)
+ .Add("vex.provenance.providerKind", provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture))
+ .Add("vex.provenance.trust.weight", provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture));
+
+ if (provider.Trust.Cosign is { } cosign)
+ {
+ builder
+ .Add("vex.provenance.cosign.issuer", cosign.Issuer)
+ .Add("vex.provenance.cosign.identityPattern", cosign.IdentityPattern);
+ }
+
+ if (!provider.Trust.PgpFingerprints.IsDefaultOrEmpty && provider.Trust.PgpFingerprints.Length > 0)
+ {
+ builder.Add("vex.provenance.pgp.fingerprints", string.Join(',', provider.Trust.PgpFingerprints));
+ }
+
+ var tier = provider.Kind.ToString().ToLowerInvariant(CultureInfo.InvariantCulture);
+ builder
+ .Add("vex.provenance.trust.tier", tier)
+ .Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
+
+ // Enrich with connector signer metadata (fingerprints, issuer tier, bundle info)
+ // from external signer metadata file (STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH)
+ ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, Logger);
+ }
+
+ private static bool TrimHistory(List digestHistory)
+ {
+ if (digestHistory.Count <= MaxDigestHistory)
+ {
return false;
}
@@ -303,55 +308,55 @@ public sealed class RancherHubConnector : VexConnectorBase
private async Task 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 static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)
- {
- if (services is null)
- {
- return;
- }
-
- var store = services.GetService();
- if (store is null)
- {
- return;
- }
-
- await store.SaveAsync(provider, cancellationToken).ConfigureAwait(false);
- }
-
- private async Task QuarantineAsync(
- RancherHubEventRecord record,
- RancherHubEventBatch batch,
- string reason,
+ 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 static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)
+ {
+ if (services is null)
+ {
+ return;
+ }
+
+ var store = services.GetService();
+ if (store is null)
+ {
+ return;
+ }
+
+ await store.SaveAsync(provider, cancellationToken).ConfigureAwait(false);
+ }
+
+ 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");
-
- AddProvenanceMetadata(builder);
- });
+ 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");
+
+ AddProvenanceMetadata(builder);
+ });
var sourceUri = record.DocumentUri ?? _metadata?.Metadata.Subscription.EventsUri ?? _options!.DiscoveryUri;
var payload = Encoding.UTF8.GetBytes(record.RawJson);
diff --git a/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.Ubuntu.CSAF/UbuntuCsafConnector.cs b/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.Ubuntu.CSAF/UbuntuCsafConnector.cs
index 55a137e0b..8f947e2e0 100644
--- a/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.Ubuntu.CSAF/UbuntuCsafConnector.cs
+++ b/src/Excititor/__Libraries/StellaOps.Excititor.Connectors.Ubuntu.CSAF/UbuntuCsafConnector.cs
@@ -461,7 +461,7 @@ public sealed class UbuntuCsafConnector : VexConnectorBase
.Add("vex.provenance.trust.tier", tier)
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
- ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
+ ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, Logger);
}
private static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)
diff --git a/src/Findings/StellaOps.Findings.Ledger.Tests/Attestation/AttestationPointerServiceTests.cs b/src/Findings/StellaOps.Findings.Ledger.Tests/Attestation/AttestationPointerServiceTests.cs
new file mode 100644
index 000000000..d1b869e85
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger.Tests/Attestation/AttestationPointerServiceTests.cs
@@ -0,0 +1,498 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using StellaOps.Findings.Ledger.Domain;
+using StellaOps.Findings.Ledger.Infrastructure;
+using StellaOps.Findings.Ledger.Infrastructure.Attestation;
+using StellaOps.Findings.Ledger.Services;
+using Xunit;
+
+namespace StellaOps.Findings.Ledger.Tests.Attestation;
+
+public class AttestationPointerServiceTests
+{
+ private readonly Mock _ledgerEventRepository;
+ private readonly Mock _writeService;
+ private readonly InMemoryAttestationPointerRepository _repository;
+ private readonly FakeTimeProvider _timeProvider;
+ private readonly AttestationPointerService _service;
+
+ public AttestationPointerServiceTests()
+ {
+ _ledgerEventRepository = new Mock();
+ _writeService = new Mock();
+ _repository = new InMemoryAttestationPointerRepository();
+ _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero));
+
+ _writeService.Setup(w => w.AppendAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((LedgerEventDraft draft, CancellationToken _) =>
+ {
+ var record = new LedgerEventRecord(
+ draft.TenantId,
+ draft.ChainId,
+ draft.SequenceNumber,
+ draft.EventId,
+ draft.EventType,
+ draft.PolicyVersion,
+ draft.FindingId,
+ draft.ArtifactId,
+ draft.SourceRunId,
+ draft.ActorId,
+ draft.ActorType,
+ draft.OccurredAt,
+ draft.RecordedAt,
+ draft.Payload,
+ "event-hash",
+ draft.ProvidedPreviousHash ?? LedgerEventConstants.EmptyHash,
+ "merkle-leaf-hash",
+ draft.CanonicalEnvelope.ToJsonString());
+ return LedgerWriteResult.Success(record);
+ });
+
+ _service = new AttestationPointerService(
+ _ledgerEventRepository.Object,
+ _writeService.Object,
+ _repository,
+ _timeProvider,
+ NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task CreatePointer_CreatesNewPointer()
+ {
+ var input = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-123",
+ AttestationType: AttestationType.DsseEnvelope,
+ Relationship: AttestationRelationship.VerifiedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:abc123def456789012345678901234567890123456789012345678901234abcd",
+ AttestationId: Guid.NewGuid(),
+ StorageUri: "s3://attestations/test.json",
+ PayloadType: "application/vnd.in-toto+json",
+ PredicateType: "https://slsa.dev/provenance/v1"),
+ VerificationResult: new VerificationResult(
+ Verified: true,
+ VerifiedAt: _timeProvider.GetUtcNow(),
+ Verifier: "stellaops-attestor",
+ VerifierVersion: "2025.01.0"),
+ CreatedBy: "test-system");
+
+ var result = await _service.CreatePointerAsync(input);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.PointerId);
+ Assert.NotNull(result.LedgerEventId);
+ Assert.Null(result.Error);
+
+ var saved = await _repository.GetByIdAsync("tenant-1", result.PointerId!.Value, CancellationToken.None);
+ Assert.NotNull(saved);
+ Assert.Equal(input.FindingId, saved!.FindingId);
+ Assert.Equal(input.AttestationType, saved.AttestationType);
+ Assert.Equal(input.AttestationRef.Digest, saved.AttestationRef.Digest);
+ }
+
+ [Fact]
+ public async Task CreatePointer_IsIdempotent()
+ {
+ var input = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-456",
+ AttestationType: AttestationType.SlsaProvenance,
+ Relationship: AttestationRelationship.AttestedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:def456789012345678901234567890123456789012345678901234567890abcd"),
+ CreatedBy: "test-system");
+
+ var result1 = await _service.CreatePointerAsync(input);
+ var result2 = await _service.CreatePointerAsync(input);
+
+ Assert.True(result1.Success);
+ Assert.True(result2.Success);
+ Assert.Equal(result1.PointerId, result2.PointerId);
+
+ var pointers = await _repository.GetByFindingIdAsync("tenant-1", "finding-456", CancellationToken.None);
+ Assert.Single(pointers);
+ }
+
+ [Fact]
+ public async Task GetPointers_ReturnsAllPointersForFinding()
+ {
+ var input1 = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-multi",
+ AttestationType: AttestationType.DsseEnvelope,
+ Relationship: AttestationRelationship.VerifiedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:aaa111222333444555666777888999000111222333444555666777888999000a"));
+
+ var input2 = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-multi",
+ AttestationType: AttestationType.VexAttestation,
+ Relationship: AttestationRelationship.DerivedFrom,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:bbb111222333444555666777888999000111222333444555666777888999000b"));
+
+ await _service.CreatePointerAsync(input1);
+ await _service.CreatePointerAsync(input2);
+
+ var pointers = await _service.GetPointersAsync("tenant-1", "finding-multi");
+
+ Assert.Equal(2, pointers.Count);
+ Assert.Contains(pointers, p => p.AttestationType == AttestationType.DsseEnvelope);
+ Assert.Contains(pointers, p => p.AttestationType == AttestationType.VexAttestation);
+ }
+
+ [Fact]
+ public async Task GetSummary_CalculatesCorrectCounts()
+ {
+ var verified = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-summary",
+ AttestationType: AttestationType.DsseEnvelope,
+ Relationship: AttestationRelationship.VerifiedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:ver111222333444555666777888999000111222333444555666777888999000a"),
+ VerificationResult: new VerificationResult(Verified: true, VerifiedAt: _timeProvider.GetUtcNow()));
+
+ var unverified = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-summary",
+ AttestationType: AttestationType.SbomAttestation,
+ Relationship: AttestationRelationship.DerivedFrom,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:unv111222333444555666777888999000111222333444555666777888999000b"));
+
+ await _service.CreatePointerAsync(verified);
+ await _service.CreatePointerAsync(unverified);
+
+ var summary = await _service.GetSummaryAsync("tenant-1", "finding-summary");
+
+ Assert.Equal("finding-summary", summary.FindingId);
+ Assert.Equal(2, summary.AttestationCount);
+ Assert.Equal(1, summary.VerifiedCount);
+ Assert.Equal(OverallVerificationStatus.PartiallyVerified, summary.OverallVerificationStatus);
+ Assert.Contains(AttestationType.DsseEnvelope, summary.AttestationTypes);
+ Assert.Contains(AttestationType.SbomAttestation, summary.AttestationTypes);
+ }
+
+ [Fact]
+ public async Task Search_FiltersByAttestationType()
+ {
+ var input1 = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-search-1",
+ AttestationType: AttestationType.DsseEnvelope,
+ Relationship: AttestationRelationship.VerifiedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:sea111222333444555666777888999000111222333444555666777888999000a"));
+
+ var input2 = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-search-2",
+ AttestationType: AttestationType.SlsaProvenance,
+ Relationship: AttestationRelationship.AttestedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:sea222333444555666777888999000111222333444555666777888999000111b"));
+
+ await _service.CreatePointerAsync(input1);
+ await _service.CreatePointerAsync(input2);
+
+ var query = new AttestationPointerQuery(
+ TenantId: "tenant-1",
+ AttestationTypes: new[] { AttestationType.DsseEnvelope });
+
+ var results = await _service.SearchAsync(query);
+
+ Assert.Single(results);
+ Assert.Equal(AttestationType.DsseEnvelope, results[0].AttestationType);
+ }
+
+ [Fact]
+ public async Task UpdateVerificationResult_UpdatesExistingPointer()
+ {
+ var input = new AttestationPointerInput(
+ TenantId: "tenant-1",
+ FindingId: "finding-update",
+ AttestationType: AttestationType.DsseEnvelope,
+ Relationship: AttestationRelationship.VerifiedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:upd111222333444555666777888999000111222333444555666777888999000a"));
+
+ var createResult = await _service.CreatePointerAsync(input);
+ Assert.True(createResult.Success);
+
+ var verificationResult = new VerificationResult(
+ Verified: true,
+ VerifiedAt: _timeProvider.GetUtcNow(),
+ Verifier: "external-verifier",
+ VerifierVersion: "1.0.0",
+ Checks: new[]
+ {
+ new VerificationCheck(VerificationCheckType.SignatureValid, true, "ECDSA verified"),
+ new VerificationCheck(VerificationCheckType.CertificateValid, true, "Chain verified")
+ });
+
+ var success = await _service.UpdateVerificationResultAsync(
+ "tenant-1", createResult.PointerId!.Value, verificationResult);
+
+ Assert.True(success);
+
+ var updated = await _repository.GetByIdAsync("tenant-1", createResult.PointerId!.Value, CancellationToken.None);
+ Assert.NotNull(updated?.VerificationResult);
+ Assert.True(updated.VerificationResult!.Verified);
+ Assert.Equal("external-verifier", updated.VerificationResult.Verifier);
+ Assert.Equal(2, updated.VerificationResult.Checks!.Count);
+ }
+
+ [Fact]
+ public async Task TenantIsolation_PreventsAccessAcrossTenants()
+ {
+ var input = new AttestationPointerInput(
+ TenantId: "tenant-a",
+ FindingId: "finding-isolated",
+ AttestationType: AttestationType.DsseEnvelope,
+ Relationship: AttestationRelationship.VerifiedBy,
+ AttestationRef: new AttestationRef(
+ Digest: "sha256:iso111222333444555666777888999000111222333444555666777888999000a"));
+
+ var result = await _service.CreatePointerAsync(input);
+ Assert.True(result.Success);
+
+ var fromTenantA = await _service.GetPointersAsync("tenant-a", "finding-isolated");
+ var fromTenantB = await _service.GetPointersAsync("tenant-b", "finding-isolated");
+
+ Assert.Single(fromTenantA);
+ Assert.Empty(fromTenantB);
+ }
+
+ private sealed class FakeTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now;
+
+ public FakeTimeProvider(DateTimeOffset now) => _now = now;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan duration) => _now = _now.Add(duration);
+ }
+}
+
+///
+/// In-memory implementation for testing.
+///
+internal sealed class InMemoryAttestationPointerRepository : IAttestationPointerRepository
+{
+ private readonly List _records = new();
+ private readonly object _lock = new();
+
+ public Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ _records.Add(record);
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task GetByIdAsync(string tenantId, Guid pointerId, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var result = _records.FirstOrDefault(r => r.TenantId == tenantId && r.PointerId == pointerId);
+ return Task.FromResult(result);
+ }
+ }
+
+ public Task> GetByFindingIdAsync(string tenantId, string findingId, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var results = _records
+ .Where(r => r.TenantId == tenantId && r.FindingId == findingId)
+ .OrderByDescending(r => r.CreatedAt)
+ .ToList();
+ return Task.FromResult>(results);
+ }
+ }
+
+ public Task> GetByDigestAsync(string tenantId, string digest, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var results = _records
+ .Where(r => r.TenantId == tenantId && r.AttestationRef.Digest == digest)
+ .OrderByDescending(r => r.CreatedAt)
+ .ToList();
+ return Task.FromResult>(results);
+ }
+ }
+
+ public Task> SearchAsync(AttestationPointerQuery query, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var results = _records.Where(r => r.TenantId == query.TenantId);
+
+ if (query.FindingIds is { Count: > 0 })
+ {
+ results = results.Where(r => query.FindingIds.Contains(r.FindingId));
+ }
+
+ if (query.AttestationTypes is { Count: > 0 })
+ {
+ results = results.Where(r => query.AttestationTypes.Contains(r.AttestationType));
+ }
+
+ if (query.VerificationStatus.HasValue)
+ {
+ results = query.VerificationStatus.Value switch
+ {
+ AttestationVerificationFilter.Verified =>
+ results.Where(r => r.VerificationResult?.Verified == true),
+ AttestationVerificationFilter.Unverified =>
+ results.Where(r => r.VerificationResult is null),
+ AttestationVerificationFilter.Failed =>
+ results.Where(r => r.VerificationResult?.Verified == false),
+ _ => results
+ };
+ }
+
+ if (query.CreatedAfter.HasValue)
+ {
+ results = results.Where(r => r.CreatedAt >= query.CreatedAfter.Value);
+ }
+
+ if (query.CreatedBefore.HasValue)
+ {
+ results = results.Where(r => r.CreatedAt <= query.CreatedBefore.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.SignerIdentity))
+ {
+ results = results.Where(r => r.AttestationRef.SignerInfo?.Subject == query.SignerIdentity);
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.PredicateType))
+ {
+ results = results.Where(r => r.AttestationRef.PredicateType == query.PredicateType);
+ }
+
+ var list = results
+ .OrderByDescending(r => r.CreatedAt)
+ .Skip(query.Offset)
+ .Take(query.Limit)
+ .ToList();
+
+ return Task.FromResult>(list);
+ }
+ }
+
+ public Task GetSummaryAsync(string tenantId, string findingId, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var pointers = _records.Where(r => r.TenantId == tenantId && r.FindingId == findingId).ToList();
+
+ if (pointers.Count == 0)
+ {
+ return Task.FromResult(new FindingAttestationSummary(
+ findingId, 0, 0, null, Array.Empty(), OverallVerificationStatus.NoAttestations));
+ }
+
+ var verifiedCount = pointers.Count(p => p.VerificationResult?.Verified == true);
+ var latest = pointers.Max(p => p.CreatedAt);
+ var types = pointers.Select(p => p.AttestationType).Distinct().ToList();
+
+ var status = pointers.Count switch
+ {
+ 0 => OverallVerificationStatus.NoAttestations,
+ _ when verifiedCount == pointers.Count => OverallVerificationStatus.AllVerified,
+ _ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
+ _ => OverallVerificationStatus.NoneVerified
+ };
+
+ return Task.FromResult(new FindingAttestationSummary(
+ findingId, pointers.Count, verifiedCount, latest, types, status));
+ }
+ }
+
+ public Task> GetSummariesAsync(string tenantId, IReadOnlyList findingIds, CancellationToken cancellationToken)
+ {
+ var tasks = findingIds.Select(fid => GetSummaryAsync(tenantId, fid, cancellationToken));
+ return Task.WhenAll(tasks).ContinueWith(t => (IReadOnlyList)t.Result.ToList());
+ }
+
+ public Task ExistsAsync(string tenantId, string findingId, string digest, AttestationType attestationType, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var exists = _records.Any(r =>
+ r.TenantId == tenantId &&
+ r.FindingId == findingId &&
+ r.AttestationRef.Digest == digest &&
+ r.AttestationType == attestationType);
+ return Task.FromResult(exists);
+ }
+ }
+
+ public Task UpdateVerificationResultAsync(string tenantId, Guid pointerId, VerificationResult verificationResult, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var idx = _records.FindIndex(r => r.TenantId == tenantId && r.PointerId == pointerId);
+ if (idx >= 0)
+ {
+ var old = _records[idx];
+ _records[idx] = old with { VerificationResult = verificationResult };
+ }
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task GetCountAsync(string tenantId, string findingId, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var count = _records.Count(r => r.TenantId == tenantId && r.FindingId == findingId);
+ return Task.FromResult(count);
+ }
+ }
+
+ public Task> GetFindingIdsWithAttestationsAsync(string tenantId, AttestationVerificationFilter? verificationFilter, IReadOnlyList? attestationTypes, int limit, int offset, CancellationToken cancellationToken)
+ {
+ lock (_lock)
+ {
+ var results = _records.Where(r => r.TenantId == tenantId);
+
+ if (attestationTypes is { Count: > 0 })
+ {
+ results = results.Where(r => attestationTypes.Contains(r.AttestationType));
+ }
+
+ if (verificationFilter.HasValue)
+ {
+ results = verificationFilter.Value switch
+ {
+ AttestationVerificationFilter.Verified =>
+ results.Where(r => r.VerificationResult?.Verified == true),
+ AttestationVerificationFilter.Unverified =>
+ results.Where(r => r.VerificationResult is null),
+ AttestationVerificationFilter.Failed =>
+ results.Where(r => r.VerificationResult?.Verified == false),
+ _ => results
+ };
+ }
+
+ var list = results
+ .Select(r => r.FindingId)
+ .Distinct()
+ .OrderBy(f => f)
+ .Skip(offset)
+ .Take(limit)
+ .ToList();
+
+ return Task.FromResult>(list);
+ }
+ }
+}
diff --git a/src/Findings/StellaOps.Findings.Ledger.Tests/Snapshot/SnapshotServiceTests.cs b/src/Findings/StellaOps.Findings.Ledger.Tests/Snapshot/SnapshotServiceTests.cs
new file mode 100644
index 000000000..23621b0d1
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger.Tests/Snapshot/SnapshotServiceTests.cs
@@ -0,0 +1,373 @@
+namespace StellaOps.Findings.Ledger.Tests.Snapshot;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using StellaOps.Findings.Ledger.Domain;
+using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
+using StellaOps.Findings.Ledger.Services;
+using Xunit;
+
+public class SnapshotServiceTests
+{
+ private readonly InMemorySnapshotRepository _snapshotRepository;
+ private readonly InMemoryTimeTravelRepository _timeTravelRepository;
+ private readonly SnapshotService _service;
+
+ public SnapshotServiceTests()
+ {
+ _snapshotRepository = new InMemorySnapshotRepository();
+ _timeTravelRepository = new InMemoryTimeTravelRepository();
+ _service = new SnapshotService(
+ _snapshotRepository,
+ _timeTravelRepository,
+ NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task CreateSnapshotAsync_CreatesSnapshotSuccessfully()
+ {
+ var input = new CreateSnapshotInput(
+ TenantId: "tenant-1",
+ Label: "test-snapshot",
+ Description: "Test description");
+
+ var result = await _service.CreateSnapshotAsync(input);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Snapshot);
+ Assert.Equal("test-snapshot", result.Snapshot.Label);
+ Assert.Equal(SnapshotStatus.Available, result.Snapshot.Status);
+ }
+
+ [Fact]
+ public async Task CreateSnapshotAsync_WithExpiry_SetsExpiresAt()
+ {
+ var input = new CreateSnapshotInput(
+ TenantId: "tenant-1",
+ Label: "expiring-snapshot",
+ ExpiresIn: TimeSpan.FromHours(24));
+
+ var result = await _service.CreateSnapshotAsync(input);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Snapshot?.ExpiresAt);
+ Assert.True(result.Snapshot.ExpiresAt > DateTimeOffset.UtcNow);
+ }
+
+ [Fact]
+ public async Task GetSnapshotAsync_ReturnsExistingSnapshot()
+ {
+ var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "get-test");
+ var createResult = await _service.CreateSnapshotAsync(input);
+
+ var snapshot = await _service.GetSnapshotAsync("tenant-1", createResult.Snapshot!.SnapshotId);
+
+ Assert.NotNull(snapshot);
+ Assert.Equal("get-test", snapshot.Label);
+ }
+
+ [Fact]
+ public async Task GetSnapshotAsync_ReturnsNullForNonExistent()
+ {
+ var snapshot = await _service.GetSnapshotAsync("tenant-1", Guid.NewGuid());
+
+ Assert.Null(snapshot);
+ }
+
+ [Fact]
+ public async Task ListSnapshotsAsync_ReturnsAllSnapshots()
+ {
+ await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-1", Label: "snap-1"));
+ await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-1", Label: "snap-2"));
+ await _service.CreateSnapshotAsync(new CreateSnapshotInput("tenant-2", Label: "snap-3"));
+
+ var (snapshots, _) = await _service.ListSnapshotsAsync(new SnapshotListQuery("tenant-1"));
+
+ Assert.Equal(2, snapshots.Count);
+ }
+
+ [Fact]
+ public async Task DeleteSnapshotAsync_MarksAsDeleted()
+ {
+ var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "to-delete");
+ var createResult = await _service.CreateSnapshotAsync(input);
+
+ var deleted = await _service.DeleteSnapshotAsync("tenant-1", createResult.Snapshot!.SnapshotId);
+
+ Assert.True(deleted);
+
+ var snapshot = await _service.GetSnapshotAsync("tenant-1", createResult.Snapshot.SnapshotId);
+ Assert.Equal(SnapshotStatus.Deleted, snapshot?.Status);
+ }
+
+ [Fact]
+ public async Task GetCurrentPointAsync_ReturnsLatestPoint()
+ {
+ var point = await _service.GetCurrentPointAsync("tenant-1");
+
+ Assert.True(point.SequenceNumber >= 0);
+ }
+
+ [Fact]
+ public async Task QueryHistoricalFindingsAsync_ReturnsItems()
+ {
+ _timeTravelRepository.AddFinding("tenant-1", new FindingHistoryItem(
+ "finding-1", "artifact-1", "CVE-2024-001", "open", 7.5m, "v1",
+ DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow, null));
+
+ var request = new HistoricalQueryRequest(
+ "tenant-1", null, null, null, EntityType.Finding, null);
+
+ var result = await _service.QueryHistoricalFindingsAsync(request);
+
+ Assert.Single(result.Items);
+ Assert.Equal("finding-1", result.Items[0].FindingId);
+ }
+
+ [Fact]
+ public async Task CheckStalenessAsync_ReturnsResult()
+ {
+ var result = await _service.CheckStalenessAsync("tenant-1", TimeSpan.FromHours(1));
+
+ Assert.NotNull(result);
+ Assert.True(result.CheckedAt <= DateTimeOffset.UtcNow);
+ }
+
+ [Fact]
+ public async Task TenantIsolation_CannotAccessOtherTenantSnapshots()
+ {
+ var input = new CreateSnapshotInput(TenantId: "tenant-1", Label: "isolated");
+ var createResult = await _service.CreateSnapshotAsync(input);
+
+ var snapshot = await _service.GetSnapshotAsync("tenant-2", createResult.Snapshot!.SnapshotId);
+
+ Assert.Null(snapshot);
+ }
+}
+
+///
+/// In-memory implementation for testing.
+///
+internal class InMemorySnapshotRepository : ISnapshotRepository
+{
+ private readonly List _snapshots = new();
+ private readonly object _lock = new();
+
+ public Task CreateAsync(
+ string tenantId,
+ CreateSnapshotInput input,
+ long currentSequence,
+ DateTimeOffset currentTimestamp,
+ CancellationToken ct = default)
+ {
+ var snapshot = new LedgerSnapshot(
+ tenantId,
+ Guid.NewGuid(),
+ input.Label,
+ input.Description,
+ SnapshotStatus.Creating,
+ DateTimeOffset.UtcNow,
+ input.ExpiresIn.HasValue ? DateTimeOffset.UtcNow.Add(input.ExpiresIn.Value) : null,
+ input.AtSequence ?? currentSequence,
+ input.AtTimestamp ?? currentTimestamp,
+ new SnapshotStatistics(0, 0, 0, 0, 0, 0),
+ null,
+ null,
+ input.Metadata);
+
+ lock (_lock)
+ {
+ _snapshots.Add(snapshot);
+ }
+
+ return Task.FromResult(snapshot);
+ }
+
+ public Task GetByIdAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ var snapshot = _snapshots.FirstOrDefault(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
+ return Task.FromResult(snapshot);
+ }
+ }
+
+ public Task<(IReadOnlyList Snapshots, string? NextPageToken)> ListAsync(
+ SnapshotListQuery query,
+ CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ var filtered = _snapshots
+ .Where(s => s.TenantId == query.TenantId)
+ .Where(s => !query.Status.HasValue || s.Status == query.Status.Value)
+ .Take(query.PageSize)
+ .ToList();
+ return Task.FromResult<(IReadOnlyList, string?)>((filtered, null));
+ }
+ }
+
+ public Task UpdateStatusAsync(string tenantId, Guid snapshotId, SnapshotStatus newStatus, CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
+ if (index < 0) return Task.FromResult(false);
+
+ _snapshots[index] = _snapshots[index] with { Status = newStatus };
+ return Task.FromResult(true);
+ }
+ }
+
+ public Task UpdateStatisticsAsync(string tenantId, Guid snapshotId, SnapshotStatistics statistics, CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
+ if (index < 0) return Task.FromResult(false);
+
+ _snapshots[index] = _snapshots[index] with { Statistics = statistics };
+ return Task.FromResult(true);
+ }
+ }
+
+ public Task SetMerkleRootAsync(string tenantId, Guid snapshotId, string merkleRoot, string? dsseDigest, CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ var index = _snapshots.FindIndex(s => s.TenantId == tenantId && s.SnapshotId == snapshotId);
+ if (index < 0) return Task.FromResult(false);
+
+ _snapshots[index] = _snapshots[index] with { MerkleRoot = merkleRoot, DsseDigest = dsseDigest };
+ return Task.FromResult(true);
+ }
+ }
+
+ public Task ExpireSnapshotsAsync(DateTimeOffset cutoff, CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ var count = 0;
+ for (int i = 0; i < _snapshots.Count; i++)
+ {
+ if (_snapshots[i].ExpiresAt.HasValue &&
+ _snapshots[i].ExpiresAt < cutoff &&
+ _snapshots[i].Status == SnapshotStatus.Available)
+ {
+ _snapshots[i] = _snapshots[i] with { Status = SnapshotStatus.Expired };
+ count++;
+ }
+ }
+ return Task.FromResult(count);
+ }
+ }
+
+ public Task DeleteAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
+ {
+ return UpdateStatusAsync(tenantId, snapshotId, SnapshotStatus.Deleted, ct);
+ }
+
+ public Task GetLatestAsync(string tenantId, CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ var snapshot = _snapshots
+ .Where(s => s.TenantId == tenantId && s.Status == SnapshotStatus.Available)
+ .OrderByDescending(s => s.CreatedAt)
+ .FirstOrDefault();
+ return Task.FromResult(snapshot);
+ }
+ }
+
+ public Task ExistsAsync(string tenantId, Guid snapshotId, CancellationToken ct = default)
+ {
+ lock (_lock)
+ {
+ return Task.FromResult(_snapshots.Any(s => s.TenantId == tenantId && s.SnapshotId == snapshotId));
+ }
+ }
+}
+
+///
+/// In-memory time-travel repository for testing.
+///
+internal class InMemoryTimeTravelRepository : ITimeTravelRepository
+{
+ private readonly Dictionary> _findings = new();
+ private readonly Dictionary> _vex = new();
+ private readonly Dictionary> _advisories = new();
+ private readonly Dictionary> _events = new();
+ private long _currentSequence = 100;
+
+ public void AddFinding(string tenantId, FindingHistoryItem finding)
+ {
+ if (!_findings.ContainsKey(tenantId))
+ _findings[tenantId] = new List();
+ _findings[tenantId].Add(finding);
+ }
+
+ public Task GetCurrentPointAsync(string tenantId, CancellationToken ct = default)
+ {
+ return Task.FromResult(new QueryPoint(DateTimeOffset.UtcNow, _currentSequence));
+ }
+
+ public Task ResolveQueryPointAsync(string tenantId, DateTimeOffset? timestamp, long? sequence, Guid? snapshotId, CancellationToken ct = default)
+ {
+ return Task.FromResult(new QueryPoint(timestamp ?? DateTimeOffset.UtcNow, sequence ?? _currentSequence, snapshotId));
+ }
+
+ public Task> QueryFindingsAsync(HistoricalQueryRequest request, CancellationToken ct = default)
+ {
+ var items = _findings.TryGetValue(request.TenantId, out var list) ? list : new List();
+ var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
+ return Task.FromResult(new HistoricalQueryResponse(queryPoint, EntityType.Finding, items, null, items.Count));
+ }
+
+ public Task> QueryVexAsync(HistoricalQueryRequest request, CancellationToken ct = default)
+ {
+ var items = _vex.TryGetValue(request.TenantId, out var list) ? list : new List();
+ var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
+ return Task.FromResult(new HistoricalQueryResponse(queryPoint, EntityType.Vex, items, null, items.Count));
+ }
+
+ public Task> QueryAdvisoriesAsync(HistoricalQueryRequest request, CancellationToken ct = default)
+ {
+ var items = _advisories.TryGetValue(request.TenantId, out var list) ? list : new List();
+ var queryPoint = new QueryPoint(DateTimeOffset.UtcNow, _currentSequence);
+ return Task.FromResult(new HistoricalQueryResponse(queryPoint, EntityType.Advisory, items, null, items.Count));
+ }
+
+ public Task<(IReadOnlyList Events, ReplayMetadata Metadata)> ReplayEventsAsync(ReplayRequest request, CancellationToken ct = default)
+ {
+ var items = _events.TryGetValue(request.TenantId, out var list) ? list : new List();
+ var metadata = new ReplayMetadata(0, _currentSequence, items.Count, false, 10);
+ return Task.FromResult<(IReadOnlyList, ReplayMetadata)>((items, metadata));
+ }
+
+ public Task ComputeDiffAsync(DiffRequest request, CancellationToken ct = default)
+ {
+ var fromPoint = new QueryPoint(request.From.Timestamp ?? DateTimeOffset.UtcNow.AddHours(-1), request.From.SequenceNumber ?? 0);
+ var toPoint = new QueryPoint(request.To.Timestamp ?? DateTimeOffset.UtcNow, request.To.SequenceNumber ?? _currentSequence);
+ var summary = new DiffSummary(0, 0, 0, 0);
+ return Task.FromResult(new DiffResponse(fromPoint, toPoint, summary, null, null));
+ }
+
+ public Task> GetChangelogAsync(string tenantId, EntityType entityType, string entityId, int limit = 100, CancellationToken ct = default)
+ {
+ return Task.FromResult>(new List());
+ }
+
+ public Task CheckStalenessAsync(string tenantId, TimeSpan threshold, CancellationToken ct = default)
+ {
+ return Task.FromResult(new StalenessResult(
+ false,
+ DateTimeOffset.UtcNow,
+ DateTimeOffset.UtcNow.AddMinutes(-5),
+ threshold,
+ TimeSpan.FromMinutes(5)));
+ }
+}
diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/AttestationPointerContracts.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/AttestationPointerContracts.cs
new file mode 100644
index 000000000..30e3cf62f
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/AttestationPointerContracts.cs
@@ -0,0 +1,328 @@
+using StellaOps.Findings.Ledger.Infrastructure.Attestation;
+
+namespace StellaOps.Findings.Ledger.WebService.Contracts;
+
+///
+/// Request to create an attestation pointer.
+///
+public sealed record CreateAttestationPointerRequest(
+ string FindingId,
+ string AttestationType,
+ string Relationship,
+ AttestationRefDto AttestationRef,
+ VerificationResultDto? VerificationResult = null,
+ string? CreatedBy = null,
+ Dictionary? Metadata = null);
+
+///
+/// Reference to an attestation artifact.
+///
+public sealed record AttestationRefDto(
+ string Digest,
+ string? AttestationId = null,
+ string? StorageUri = null,
+ string? PayloadType = null,
+ string? PredicateType = null,
+ IReadOnlyList? SubjectDigests = null,
+ SignerInfoDto? SignerInfo = null,
+ RekorEntryRefDto? RekorEntry = null);
+
+///
+/// Information about the attestation signer.
+///
+public sealed record SignerInfoDto(
+ string? KeyId = null,
+ string? Issuer = null,
+ string? Subject = null,
+ IReadOnlyList? CertificateChain = null,
+ DateTimeOffset? SignedAt = null);
+
+///
+/// Reference to Rekor transparency log entry.
+///
+public sealed record RekorEntryRefDto(
+ long? LogIndex = null,
+ string? LogId = null,
+ string? Uuid = null,
+ long? IntegratedTime = null);
+
+///
+/// Result of attestation verification.
+///
+public sealed record VerificationResultDto(
+ bool Verified,
+ DateTimeOffset VerifiedAt,
+ string? Verifier = null,
+ string? VerifierVersion = null,
+ string? PolicyRef = null,
+ IReadOnlyList? Checks = null,
+ IReadOnlyList? Warnings = null,
+ IReadOnlyList? Errors = null);
+
+///
+/// Individual verification check result.
+///
+public sealed record VerificationCheckDto(
+ string CheckType,
+ bool Passed,
+ string? Details = null,
+ Dictionary? Evidence = null);
+
+///
+/// Response for creating an attestation pointer.
+///
+public sealed record CreateAttestationPointerResponse(
+ bool Success,
+ string? PointerId,
+ string? LedgerEventId,
+ string? Error);
+
+///
+/// Response for getting attestation pointers.
+///
+public sealed record AttestationPointerResponse(
+ string PointerId,
+ string FindingId,
+ string AttestationType,
+ string Relationship,
+ AttestationRefDto AttestationRef,
+ VerificationResultDto? VerificationResult,
+ DateTimeOffset CreatedAt,
+ string CreatedBy,
+ Dictionary? Metadata,
+ string? LedgerEventId);
+
+///
+/// Response for attestation summary.
+///
+public sealed record AttestationSummaryResponse(
+ string FindingId,
+ int AttestationCount,
+ int VerifiedCount,
+ DateTimeOffset? LatestAttestation,
+ IReadOnlyList AttestationTypes,
+ string OverallVerificationStatus);
+
+///
+/// Query parameters for searching attestation pointers.
+///
+public sealed record AttestationPointerSearchRequest(
+ IReadOnlyList? FindingIds = null,
+ IReadOnlyList? AttestationTypes = null,
+ string? VerificationStatus = null,
+ DateTimeOffset? CreatedAfter = null,
+ DateTimeOffset? CreatedBefore = null,
+ string? SignerIdentity = null,
+ string? PredicateType = null,
+ int Limit = 100,
+ int Offset = 0);
+
+///
+/// Response for searching attestation pointers.
+///
+public sealed record AttestationPointerSearchResponse(
+ IReadOnlyList Pointers,
+ int TotalCount);
+
+///
+/// Request to update verification result.
+///
+public sealed record UpdateVerificationResultRequest(
+ VerificationResultDto VerificationResult);
+
+///
+/// Mapping extensions for attestation pointer DTOs.
+///
+public static class AttestationPointerMappings
+{
+ public static AttestationPointerInput ToInput(this CreateAttestationPointerRequest request, string tenantId)
+ {
+ if (!Enum.TryParse(request.AttestationType, ignoreCase: true, out var attestationType))
+ {
+ throw new ArgumentException($"Invalid attestation type: {request.AttestationType}");
+ }
+
+ if (!Enum.TryParse(request.Relationship, ignoreCase: true, out var relationship))
+ {
+ throw new ArgumentException($"Invalid relationship: {request.Relationship}");
+ }
+
+ return new AttestationPointerInput(
+ tenantId,
+ request.FindingId,
+ attestationType,
+ relationship,
+ request.AttestationRef.ToModel(),
+ request.VerificationResult?.ToModel(),
+ request.CreatedBy,
+ request.Metadata);
+ }
+
+ public static AttestationRef ToModel(this AttestationRefDto dto)
+ {
+ return new AttestationRef(
+ dto.Digest,
+ dto.AttestationId is not null ? Guid.Parse(dto.AttestationId) : null,
+ dto.StorageUri,
+ dto.PayloadType,
+ dto.PredicateType,
+ dto.SubjectDigests,
+ dto.SignerInfo?.ToModel(),
+ dto.RekorEntry?.ToModel());
+ }
+
+ public static SignerInfo ToModel(this SignerInfoDto dto)
+ {
+ return new SignerInfo(
+ dto.KeyId,
+ dto.Issuer,
+ dto.Subject,
+ dto.CertificateChain,
+ dto.SignedAt);
+ }
+
+ public static RekorEntryRef ToModel(this RekorEntryRefDto dto)
+ {
+ return new RekorEntryRef(
+ dto.LogIndex,
+ dto.LogId,
+ dto.Uuid,
+ dto.IntegratedTime);
+ }
+
+ public static VerificationResult ToModel(this VerificationResultDto dto)
+ {
+ return new VerificationResult(
+ dto.Verified,
+ dto.VerifiedAt,
+ dto.Verifier,
+ dto.VerifierVersion,
+ dto.PolicyRef,
+ dto.Checks?.Select(c => c.ToModel()).ToList(),
+ dto.Warnings,
+ dto.Errors);
+ }
+
+ public static VerificationCheck ToModel(this VerificationCheckDto dto)
+ {
+ if (!Enum.TryParse(dto.CheckType, ignoreCase: true, out var checkType))
+ {
+ throw new ArgumentException($"Invalid check type: {dto.CheckType}");
+ }
+
+ return new VerificationCheck(checkType, dto.Passed, dto.Details, dto.Evidence);
+ }
+
+ public static AttestationPointerResponse ToResponse(this AttestationPointerRecord record)
+ {
+ return new AttestationPointerResponse(
+ record.PointerId.ToString(),
+ record.FindingId,
+ record.AttestationType.ToString(),
+ record.Relationship.ToString(),
+ record.AttestationRef.ToDto(),
+ record.VerificationResult?.ToDto(),
+ record.CreatedAt,
+ record.CreatedBy,
+ record.Metadata,
+ record.LedgerEventId?.ToString());
+ }
+
+ public static AttestationRefDto ToDto(this AttestationRef model)
+ {
+ return new AttestationRefDto(
+ model.Digest,
+ model.AttestationId?.ToString(),
+ model.StorageUri,
+ model.PayloadType,
+ model.PredicateType,
+ model.SubjectDigests,
+ model.SignerInfo?.ToDto(),
+ model.RekorEntry?.ToDto());
+ }
+
+ public static SignerInfoDto ToDto(this SignerInfo model)
+ {
+ return new SignerInfoDto(
+ model.KeyId,
+ model.Issuer,
+ model.Subject,
+ model.CertificateChain,
+ model.SignedAt);
+ }
+
+ public static RekorEntryRefDto ToDto(this RekorEntryRef model)
+ {
+ return new RekorEntryRefDto(
+ model.LogIndex,
+ model.LogId,
+ model.Uuid,
+ model.IntegratedTime);
+ }
+
+ public static VerificationResultDto ToDto(this VerificationResult model)
+ {
+ return new VerificationResultDto(
+ model.Verified,
+ model.VerifiedAt,
+ model.Verifier,
+ model.VerifierVersion,
+ model.PolicyRef,
+ model.Checks?.Select(c => c.ToDto()).ToList(),
+ model.Warnings,
+ model.Errors);
+ }
+
+ public static VerificationCheckDto ToDto(this VerificationCheck model)
+ {
+ return new VerificationCheckDto(
+ model.CheckType.ToString(),
+ model.Passed,
+ model.Details,
+ model.Evidence);
+ }
+
+ public static AttestationSummaryResponse ToResponse(this FindingAttestationSummary summary)
+ {
+ return new AttestationSummaryResponse(
+ summary.FindingId,
+ summary.AttestationCount,
+ summary.VerifiedCount,
+ summary.LatestAttestation,
+ summary.AttestationTypes.Select(t => t.ToString()).ToList(),
+ summary.OverallVerificationStatus.ToString());
+ }
+
+ public static AttestationPointerQuery ToQuery(this AttestationPointerSearchRequest request, string tenantId)
+ {
+ IReadOnlyList? attestationTypes = null;
+ if (request.AttestationTypes is { Count: > 0 })
+ {
+ attestationTypes = request.AttestationTypes
+ .Where(t => Enum.TryParse(t, ignoreCase: true, out _))
+ .Select(t => Enum.Parse(t, ignoreCase: true))
+ .ToList();
+ }
+
+ AttestationVerificationFilter? verificationFilter = null;
+ if (!string.IsNullOrWhiteSpace(request.VerificationStatus))
+ {
+ if (Enum.TryParse(request.VerificationStatus, ignoreCase: true, out var filter))
+ {
+ verificationFilter = filter;
+ }
+ }
+
+ return new AttestationPointerQuery(
+ tenantId,
+ request.FindingIds,
+ attestationTypes,
+ verificationFilter,
+ request.CreatedAfter,
+ request.CreatedBefore,
+ request.SignerIdentity,
+ request.PredicateType,
+ request.Limit,
+ request.Offset);
+ }
+}
diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/SnapshotContracts.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/SnapshotContracts.cs
new file mode 100644
index 000000000..7bca1a4a0
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Contracts/SnapshotContracts.cs
@@ -0,0 +1,460 @@
+namespace StellaOps.Findings.Ledger.WebService.Contracts;
+
+using StellaOps.Findings.Ledger.Domain;
+using StellaOps.Findings.Ledger.Infrastructure.Snapshot;
+
+// === Snapshot Contracts ===
+
+///
+/// Request to create a snapshot.
+///
+public sealed record CreateSnapshotRequest(
+ string? Label = null,
+ string? Description = null,
+ DateTimeOffset? AtTimestamp = null,
+ long? AtSequence = null,
+ int? ExpiresInHours = null,
+ IReadOnlyList? IncludeEntityTypes = null,
+ bool Sign = false,
+ Dictionary? Metadata = null)
+{
+ public CreateSnapshotInput ToInput(string tenantId) => new(
+ TenantId: tenantId,
+ Label: Label,
+ Description: Description,
+ AtTimestamp: AtTimestamp,
+ AtSequence: AtSequence,
+ ExpiresIn: ExpiresInHours.HasValue ? TimeSpan.FromHours(ExpiresInHours.Value) : null,
+ IncludeEntityTypes: IncludeEntityTypes?.Select(ParseEntityType).ToList(),
+ Sign: Sign,
+ Metadata: Metadata);
+
+ private static EntityType ParseEntityType(string s) =>
+ Enum.TryParse(s, true, out var et) ? et : EntityType.Finding;
+}
+
+///
+/// Response for a snapshot.
+///
+public sealed record SnapshotResponse(
+ Guid SnapshotId,
+ string? Label,
+ string? Description,
+ string Status,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? ExpiresAt,
+ long SequenceNumber,
+ DateTimeOffset Timestamp,
+ SnapshotStatisticsResponse Statistics,
+ string? MerkleRoot,
+ string? DsseDigest,
+ Dictionary? Metadata);
+
+///
+/// Response for snapshot statistics.
+///
+public sealed record SnapshotStatisticsResponse(
+ long FindingsCount,
+ long VexStatementsCount,
+ long AdvisoriesCount,
+ long SbomsCount,
+ long EventsCount,
+ long SizeBytes);
+
+///
+/// Result of creating a snapshot.
+///
+public sealed record CreateSnapshotResponse(
+ bool Success,
+ SnapshotResponse? Snapshot,
+ string? Error);
+
+///
+/// Response for listing snapshots.
+///
+public sealed record SnapshotListResponse(
+ IReadOnlyList Snapshots,
+ string? NextPageToken);
+
+// === Time-Travel Contracts ===
+
+///
+/// Request for historical query.
+///
+public sealed record HistoricalQueryApiRequest(
+ DateTimeOffset? AtTimestamp = null,
+ long? AtSequence = null,
+ Guid? SnapshotId = null,
+ string? Status = null,
+ decimal? SeverityMin = null,
+ decimal? SeverityMax = null,
+ string? PolicyVersion = null,
+ string? ArtifactId = null,
+ string? VulnId = null,
+ int PageSize = 500,
+ string? PageToken = null)
+{
+ public HistoricalQueryRequest ToRequest(string tenantId, EntityType entityType) => new(
+ TenantId: tenantId,
+ AtTimestamp: AtTimestamp,
+ AtSequence: AtSequence,
+ SnapshotId: SnapshotId,
+ EntityType: entityType,
+ Filters: new TimeQueryFilters(
+ Status: Status,
+ SeverityMin: SeverityMin,
+ SeverityMax: SeverityMax,
+ PolicyVersion: PolicyVersion,
+ ArtifactId: ArtifactId,
+ VulnId: VulnId),
+ PageSize: PageSize,
+ PageToken: PageToken);
+}
+
+///
+/// Response for historical query.
+///
+public sealed record HistoricalQueryApiResponse(
+ QueryPointResponse QueryPoint,
+ string EntityType,
+ IReadOnlyList Items,
+ string? NextPageToken,
+ long TotalCount);
+
+///
+/// Query point response.
+///
+public sealed record QueryPointResponse(
+ DateTimeOffset Timestamp,
+ long SequenceNumber,
+ Guid? SnapshotId);
+
+///
+/// Finding history item response.
+///
+public sealed record FindingHistoryResponse(
+ string FindingId,
+ string ArtifactId,
+ string VulnId,
+ string Status,
+ decimal? Severity,
+ string? PolicyVersion,
+ DateTimeOffset FirstSeen,
+ DateTimeOffset LastUpdated,
+ Dictionary? Labels);
+
+///
+/// VEX history item response.
+///
+public sealed record VexHistoryResponse(
+ string StatementId,
+ string VulnId,
+ string ProductId,
+ string Status,
+ string? Justification,
+ DateTimeOffset IssuedAt,
+ DateTimeOffset? ExpiresAt);
+
+///
+/// Advisory history item response.
+///
+public sealed record AdvisoryHistoryResponse(
+ string AdvisoryId,
+ string Source,
+ string Title,
+ decimal? CvssScore,
+ DateTimeOffset PublishedAt,
+ DateTimeOffset? ModifiedAt);
+
+// === Replay Contracts ===
+
+///
+/// Request for replaying events.
+///
+public sealed record ReplayApiRequest(
+ long? FromSequence = null,
+ long? ToSequence = null,
+ DateTimeOffset? FromTimestamp = null,
+ DateTimeOffset? ToTimestamp = null,
+ IReadOnlyList? ChainIds = null,
+ IReadOnlyList? EventTypes = null,
+ bool IncludePayload = true,
+ int PageSize = 1000)
+{
+ public ReplayRequest ToRequest(string tenantId) => new(
+ TenantId: tenantId,
+ FromSequence: FromSequence,
+ ToSequence: ToSequence,
+ FromTimestamp: FromTimestamp,
+ ToTimestamp: ToTimestamp,
+ ChainIds: ChainIds,
+ EventTypes: EventTypes,
+ IncludePayload: IncludePayload,
+ PageSize: PageSize);
+}
+
+///
+/// Response for replay.
+///
+public sealed record ReplayApiResponse(
+ IReadOnlyList Events,
+ ReplayMetadataResponse Metadata);
+
+///
+/// Replay event response.
+///
+public sealed record ReplayEventResponse(
+ Guid EventId,
+ long SequenceNumber,
+ Guid ChainId,
+ int ChainSequence,
+ string EventType,
+ DateTimeOffset OccurredAt,
+ DateTimeOffset RecordedAt,
+ string? ActorId,
+ string? ActorType,
+ string? ArtifactId,
+ string? FindingId,
+ string? PolicyVersion,
+ string EventHash,
+ string PreviousHash,
+ object? Payload);
+
+///
+/// Replay metadata response.
+///
+public sealed record ReplayMetadataResponse(
+ long FromSequence,
+ long ToSequence,
+ long EventsCount,
+ bool HasMore,
+ long ReplayDurationMs);
+
+// === Diff Contracts ===
+
+///
+/// Request for computing diff.
+///
+public sealed record DiffApiRequest(
+ DiffPointRequest From,
+ DiffPointRequest To,
+ IReadOnlyList? EntityTypes = null,
+ bool IncludeUnchanged = false,
+ string OutputFormat = "Summary")
+{
+ public DiffRequest ToRequest(string tenantId) => new(
+ TenantId: tenantId,
+ From: From.ToDiffPoint(),
+ To: To.ToDiffPoint(),
+ EntityTypes: EntityTypes?.Select(ParseEntityType).ToList(),
+ IncludeUnchanged: IncludeUnchanged,
+ OutputFormat: Enum.TryParse(OutputFormat, true, out var fmt)
+ ? fmt : DiffOutputFormat.Summary);
+
+ private static EntityType ParseEntityType(string s) =>
+ Enum.TryParse(s, true, out var et) ? et : EntityType.Finding;
+}
+
+///
+/// Diff point request.
+///
+public sealed record DiffPointRequest(
+ DateTimeOffset? Timestamp = null,
+ long? SequenceNumber = null,
+ Guid? SnapshotId = null)
+{
+ public DiffPoint ToDiffPoint() => new(Timestamp, SequenceNumber, SnapshotId);
+}
+
+///
+/// Response for diff.
+///
+public sealed record DiffApiResponse(
+ QueryPointResponse FromPoint,
+ QueryPointResponse ToPoint,
+ DiffSummaryResponse Summary,
+ IReadOnlyList? Changes,
+ string? NextPageToken);
+
+///
+/// Diff summary response.
+///
+public sealed record DiffSummaryResponse(
+ int Added,
+ int Modified,
+ int Removed,
+ int Unchanged,
+ Dictionary? ByEntityType);
+
+///
+/// Diff counts response.
+///
+public sealed record DiffCountsResponse(int Added, int Modified, int Removed);
+
+///
+/// Diff entry response.
+///
+public sealed record DiffEntryResponse(
+ string EntityType,
+ string EntityId,
+ string ChangeType,
+ object? FromState,
+ object? ToState,
+ IReadOnlyList? ChangedFields);
+
+// === Changelog Contracts ===
+
+///
+/// Changelog entry response.
+///
+public sealed record ChangeLogEntryResponse(
+ long SequenceNumber,
+ DateTimeOffset Timestamp,
+ string EntityType,
+ string EntityId,
+ string EventType,
+ string? EventHash,
+ string? ActorId,
+ string? Summary);
+
+// === Staleness Contracts ===
+
+///
+/// Staleness check response.
+///
+public sealed record StalenessResponse(
+ bool IsStale,
+ DateTimeOffset CheckedAt,
+ DateTimeOffset? LastEventAt,
+ string StalenessThreshold,
+ string? StalenessDuration,
+ Dictionary? ByEntityType);
+
+///
+/// Entity staleness response.
+///
+public sealed record EntityStalenessResponse(
+ bool IsStale,
+ DateTimeOffset? LastEventAt,
+ long EventsBehind);
+
+// === Extension Methods ===
+
+public static class SnapshotExtensions
+{
+ public static SnapshotResponse ToResponse(this LedgerSnapshot snapshot) => new(
+ SnapshotId: snapshot.SnapshotId,
+ Label: snapshot.Label,
+ Description: snapshot.Description,
+ Status: snapshot.Status.ToString(),
+ CreatedAt: snapshot.CreatedAt,
+ ExpiresAt: snapshot.ExpiresAt,
+ SequenceNumber: snapshot.SequenceNumber,
+ Timestamp: snapshot.Timestamp,
+ Statistics: snapshot.Statistics.ToResponse(),
+ MerkleRoot: snapshot.MerkleRoot,
+ DsseDigest: snapshot.DsseDigest,
+ Metadata: snapshot.Metadata);
+
+ public static SnapshotStatisticsResponse ToResponse(this SnapshotStatistics stats) => new(
+ FindingsCount: stats.FindingsCount,
+ VexStatementsCount: stats.VexStatementsCount,
+ AdvisoriesCount: stats.AdvisoriesCount,
+ SbomsCount: stats.SbomsCount,
+ EventsCount: stats.EventsCount,
+ SizeBytes: stats.SizeBytes);
+
+ public static QueryPointResponse ToResponse(this QueryPoint point) => new(
+ Timestamp: point.Timestamp,
+ SequenceNumber: point.SequenceNumber,
+ SnapshotId: point.SnapshotId);
+
+ public static FindingHistoryResponse ToResponse(this FindingHistoryItem item) => new(
+ FindingId: item.FindingId,
+ ArtifactId: item.ArtifactId,
+ VulnId: item.VulnId,
+ Status: item.Status,
+ Severity: item.Severity,
+ PolicyVersion: item.PolicyVersion,
+ FirstSeen: item.FirstSeen,
+ LastUpdated: item.LastUpdated,
+ Labels: item.Labels);
+
+ public static VexHistoryResponse ToResponse(this VexHistoryItem item) => new(
+ StatementId: item.StatementId,
+ VulnId: item.VulnId,
+ ProductId: item.ProductId,
+ Status: item.Status,
+ Justification: item.Justification,
+ IssuedAt: item.IssuedAt,
+ ExpiresAt: item.ExpiresAt);
+
+ public static AdvisoryHistoryResponse ToResponse(this AdvisoryHistoryItem item) => new(
+ AdvisoryId: item.AdvisoryId,
+ Source: item.Source,
+ Title: item.Title,
+ CvssScore: item.CvssScore,
+ PublishedAt: item.PublishedAt,
+ ModifiedAt: item.ModifiedAt);
+
+ public static ReplayEventResponse ToResponse(this ReplayEvent e) => new(
+ EventId: e.EventId,
+ SequenceNumber: e.SequenceNumber,
+ ChainId: e.ChainId,
+ ChainSequence: e.ChainSequence,
+ EventType: e.EventType,
+ OccurredAt: e.OccurredAt,
+ RecordedAt: e.RecordedAt,
+ ActorId: e.ActorId,
+ ActorType: e.ActorType,
+ ArtifactId: e.ArtifactId,
+ FindingId: e.FindingId,
+ PolicyVersion: e.PolicyVersion,
+ EventHash: e.EventHash,
+ PreviousHash: e.PreviousHash,
+ Payload: e.Payload);
+
+ public static ReplayMetadataResponse ToResponse(this ReplayMetadata m) => new(
+ FromSequence: m.FromSequence,
+ ToSequence: m.ToSequence,
+ EventsCount: m.EventsCount,
+ HasMore: m.HasMore,
+ ReplayDurationMs: m.ReplayDurationMs);
+
+ public static DiffSummaryResponse ToResponse(this DiffSummary summary) => new(
+ Added: summary.Added,
+ Modified: summary.Modified,
+ Removed: summary.Removed,
+ Unchanged: summary.Unchanged,
+ ByEntityType: summary.ByEntityType?.ToDictionary(
+ kv => kv.Key.ToString(),
+ kv => new DiffCountsResponse(kv.Value.Added, kv.Value.Modified, kv.Value.Removed)));
+
+ public static DiffEntryResponse ToResponse(this DiffEntry entry) => new(
+ EntityType: entry.EntityType.ToString(),
+ EntityId: entry.EntityId,
+ ChangeType: entry.ChangeType.ToString(),
+ FromState: entry.FromState,
+ ToState: entry.ToState,
+ ChangedFields: entry.ChangedFields);
+
+ public static ChangeLogEntryResponse ToResponse(this ChangeLogEntry entry) => new(
+ SequenceNumber: entry.SequenceNumber,
+ Timestamp: entry.Timestamp,
+ EntityType: entry.EntityType.ToString(),
+ EntityId: entry.EntityId,
+ EventType: entry.EventType,
+ EventHash: entry.EventHash,
+ ActorId: entry.ActorId,
+ Summary: entry.Summary);
+
+ public static StalenessResponse ToResponse(this StalenessResult result) => new(
+ IsStale: result.IsStale,
+ CheckedAt: result.CheckedAt,
+ LastEventAt: result.LastEventAt,
+ StalenessThreshold: result.StalenessThreshold.ToString(),
+ StalenessDuration: result.StalenessDuration?.ToString(),
+ ByEntityType: result.ByEntityType?.ToDictionary(
+ kv => kv.Key.ToString(),
+ kv => new EntityStalenessResponse(kv.Value.IsStale, kv.Value.LastEventAt, kv.Value.EventsBehind)));
+}
diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs
index 2a1ea520f..0dc10d172 100644
--- a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs
+++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs
@@ -155,6 +155,14 @@ builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
var app = builder.Build();
@@ -633,6 +641,206 @@ app.MapPost("/internal/ledger/airgap-import", async Task, Ok, ProblemHttpResult>> (
+ HttpContext httpContext,
+ CreateAttestationPointerRequest request,
+ AttestationPointerService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ try
+ {
+ var input = request.ToInput(tenantId);
+ var result = await service.CreatePointerAsync(input, cancellationToken).ConfigureAwait(false);
+
+ var response = new CreateAttestationPointerResponse(
+ result.Success,
+ result.PointerId?.ToString(),
+ result.LedgerEventId?.ToString(),
+ result.Error);
+
+ if (!result.Success)
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "attestation_pointer_failed",
+ detail: result.Error);
+ }
+
+ return TypedResults.Created($"/v1/ledger/attestation-pointers/{result.PointerId}", response);
+ }
+ catch (ArgumentException ex)
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_request",
+ detail: ex.Message);
+ }
+})
+.WithName("CreateAttestationPointer")
+.RequireAuthorization(LedgerWritePolicy)
+.Produces(StatusCodes.Status201Created)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapGet("/v1/ledger/attestation-pointers/{pointerId}", async Task, NotFound, ProblemHttpResult>> (
+ HttpContext httpContext,
+ string pointerId,
+ AttestationPointerService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ if (!Guid.TryParse(pointerId, out var pointerGuid))
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_pointer_id",
+ detail: "Pointer ID must be a valid GUID.");
+ }
+
+ var pointer = await service.GetPointerAsync(tenantId, pointerGuid, cancellationToken).ConfigureAwait(false);
+ if (pointer is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ return TypedResults.Json(pointer.ToResponse());
+})
+.WithName("GetAttestationPointer")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.Produces(StatusCodes.Status404NotFound)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapGet("/v1/ledger/findings/{findingId}/attestation-pointers", async Task>, ProblemHttpResult>> (
+ HttpContext httpContext,
+ string findingId,
+ AttestationPointerService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var pointers = await service.GetPointersAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
+ IReadOnlyList responseList = pointers.Select(p => p.ToResponse()).ToList();
+ return TypedResults.Json(responseList);
+})
+.WithName("GetFindingAttestationPointers")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapGet("/v1/ledger/findings/{findingId}/attestation-summary", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ string findingId,
+ AttestationPointerService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var summary = await service.GetSummaryAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
+ return TypedResults.Json(summary.ToResponse());
+})
+.WithName("GetFindingAttestationSummary")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapPost("/v1/ledger/attestation-pointers/search", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ AttestationPointerSearchRequest request,
+ AttestationPointerService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ try
+ {
+ var query = request.ToQuery(tenantId);
+ var pointers = await service.SearchAsync(query, cancellationToken).ConfigureAwait(false);
+
+ var response = new AttestationPointerSearchResponse(
+ pointers.Select(p => p.ToResponse()).ToList(),
+ pointers.Count);
+
+ return TypedResults.Json(response);
+ }
+ catch (ArgumentException ex)
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_request",
+ detail: ex.Message);
+ }
+})
+.WithName("SearchAttestationPointers")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Task> (
+ HttpContext httpContext,
+ string pointerId,
+ UpdateVerificationResultRequest request,
+ AttestationPointerService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ if (!Guid.TryParse(pointerId, out var pointerGuid))
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_pointer_id",
+ detail: "Pointer ID must be a valid GUID.");
+ }
+
+ try
+ {
+ var verificationResult = request.VerificationResult.ToModel();
+ var success = await service.UpdateVerificationResultAsync(tenantId, pointerGuid, verificationResult, cancellationToken).ConfigureAwait(false);
+
+ if (!success)
+ {
+ return TypedResults.NotFound();
+ }
+
+ return TypedResults.NoContent();
+ }
+ catch (ArgumentException ex)
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_request",
+ detail: ex.Message);
+ }
+})
+.WithName("UpdateAttestationPointerVerification")
+.RequireAuthorization(LedgerWritePolicy)
+.Produces(StatusCodes.Status204NoContent)
+.Produces(StatusCodes.Status404NotFound)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
app.MapGet("/.well-known/openapi", () =>
{
var contentRoot = AppContext.BaseDirectory;
@@ -649,6 +857,383 @@ app.MapGet("/.well-known/openapi", () =>
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError);
+// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)
+app.MapPost("/v1/ledger/snapshots", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ CreateSnapshotRequest request,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var input = request.ToInput(tenantId);
+ var result = await service.CreateSnapshotAsync(input, cancellationToken).ConfigureAwait(false);
+
+ var response = new CreateSnapshotResponse(
+ result.Success,
+ result.Snapshot?.ToResponse(),
+ result.Error);
+
+ if (!result.Success)
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "snapshot_creation_failed",
+ detail: result.Error);
+ }
+
+ return TypedResults.Created($"/v1/ledger/snapshots/{result.Snapshot!.SnapshotId}", response);
+})
+.WithName("CreateSnapshot")
+.RequireAuthorization(LedgerWritePolicy)
+.Produces(StatusCodes.Status201Created)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapGet("/v1/ledger/snapshots", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var statusStr = httpContext.Request.Query["status"].ToString();
+ Domain.SnapshotStatus? status = null;
+ if (!string.IsNullOrEmpty(statusStr) && Enum.TryParse(statusStr, true, out var parsedStatus))
+ {
+ status = parsedStatus;
+ }
+
+ var query = new Domain.SnapshotListQuery(
+ tenantId,
+ status,
+ ParseDate(httpContext.Request.Query["created_after"]),
+ ParseDate(httpContext.Request.Query["created_before"]),
+ ParseInt(httpContext.Request.Query["page_size"]) ?? 100,
+ httpContext.Request.Query["page_token"].ToString());
+
+ var (snapshots, nextPageToken) = await service.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
+
+ var response = new SnapshotListResponse(
+ snapshots.Select(s => s.ToResponse()).ToList(),
+ nextPageToken);
+
+ return TypedResults.Json(response);
+})
+.WithName("ListSnapshots")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapGet("/v1/ledger/snapshots/{snapshotId}", async Task, NotFound, ProblemHttpResult>> (
+ HttpContext httpContext,
+ string snapshotId,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ if (!Guid.TryParse(snapshotId, out var snapshotGuid))
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_snapshot_id",
+ detail: "Snapshot ID must be a valid GUID.");
+ }
+
+ var snapshot = await service.GetSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
+ if (snapshot is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ return TypedResults.Json(snapshot.ToResponse());
+})
+.WithName("GetSnapshot")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.Produces(StatusCodes.Status404NotFound)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapDelete("/v1/ledger/snapshots/{snapshotId}", async Task> (
+ HttpContext httpContext,
+ string snapshotId,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ if (!Guid.TryParse(snapshotId, out var snapshotGuid))
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_snapshot_id",
+ detail: "Snapshot ID must be a valid GUID.");
+ }
+
+ var deleted = await service.DeleteSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
+ if (!deleted)
+ {
+ return TypedResults.NotFound();
+ }
+
+ return TypedResults.NoContent();
+})
+.WithName("DeleteSnapshot")
+.RequireAuthorization(LedgerWritePolicy)
+.Produces(StatusCodes.Status204NoContent)
+.Produces(StatusCodes.Status404NotFound)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+// Time-Travel Query Endpoints
+app.MapGet("/v1/ledger/time-travel/findings", async Task>, ProblemHttpResult>> (
+ HttpContext httpContext,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var request = new HistoricalQueryApiRequest(
+ AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
+ AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
+ SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
+ Status: httpContext.Request.Query["status"].ToString(),
+ SeverityMin: ParseDecimal(httpContext.Request.Query["severity_min"]),
+ SeverityMax: ParseDecimal(httpContext.Request.Query["severity_max"]),
+ PolicyVersion: httpContext.Request.Query["policy_version"].ToString(),
+ ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
+ VulnId: httpContext.Request.Query["vuln_id"].ToString(),
+ PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
+ PageToken: httpContext.Request.Query["page_token"].ToString());
+
+ var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Finding);
+ var result = await service.QueryHistoricalFindingsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
+
+ var response = new HistoricalQueryApiResponse(
+ result.QueryPoint.ToResponse(),
+ "Finding",
+ result.Items.Select(i => i.ToResponse()).ToList(),
+ result.NextPageToken,
+ result.TotalCount);
+
+ return TypedResults.Json(response);
+})
+.WithName("TimeTravelQueryFindings")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapGet("/v1/ledger/time-travel/vex", async Task>, ProblemHttpResult>> (
+ HttpContext httpContext,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var request = new HistoricalQueryApiRequest(
+ AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
+ AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
+ SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
+ PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
+ PageToken: httpContext.Request.Query["page_token"].ToString());
+
+ var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Vex);
+ var result = await service.QueryHistoricalVexAsync(domainRequest, cancellationToken).ConfigureAwait(false);
+
+ var response = new HistoricalQueryApiResponse(
+ result.QueryPoint.ToResponse(),
+ "Vex",
+ result.Items.Select(i => i.ToResponse()).ToList(),
+ result.NextPageToken,
+ result.TotalCount);
+
+ return TypedResults.Json(response);
+})
+.WithName("TimeTravelQueryVex")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+app.MapGet("/v1/ledger/time-travel/advisories", async Task>, ProblemHttpResult>> (
+ HttpContext httpContext,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var request = new HistoricalQueryApiRequest(
+ AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
+ AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
+ SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
+ PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
+ PageToken: httpContext.Request.Query["page_token"].ToString());
+
+ var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Advisory);
+ var result = await service.QueryHistoricalAdvisoriesAsync(domainRequest, cancellationToken).ConfigureAwait(false);
+
+ var response = new HistoricalQueryApiResponse(
+ result.QueryPoint.ToResponse(),
+ "Advisory",
+ result.Items.Select(i => i.ToResponse()).ToList(),
+ result.NextPageToken,
+ result.TotalCount);
+
+ return TypedResults.Json(response);
+})
+.WithName("TimeTravelQueryAdvisories")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+// Replay Endpoint
+app.MapPost("/v1/ledger/replay", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ ReplayApiRequest request,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var domainRequest = request.ToRequest(tenantId);
+ var (events, metadata) = await service.ReplayEventsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
+
+ var response = new ReplayApiResponse(
+ events.Select(e => e.ToResponse()).ToList(),
+ metadata.ToResponse());
+
+ return TypedResults.Json(response);
+})
+.WithName("ReplayEvents")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+// Diff Endpoint
+app.MapPost("/v1/ledger/diff", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ DiffApiRequest request,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var domainRequest = request.ToRequest(tenantId);
+ var result = await service.ComputeDiffAsync(domainRequest, cancellationToken).ConfigureAwait(false);
+
+ var response = new DiffApiResponse(
+ result.FromPoint.ToResponse(),
+ result.ToPoint.ToResponse(),
+ result.Summary.ToResponse(),
+ result.Changes?.Select(c => c.ToResponse()).ToList(),
+ result.NextPageToken);
+
+ return TypedResults.Json(response);
+})
+.WithName("ComputeDiff")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+// Changelog Endpoint
+app.MapGet("/v1/ledger/changelog/{entityType}/{entityId}", async Task>, ProblemHttpResult>> (
+ HttpContext httpContext,
+ string entityType,
+ string entityId,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ if (!Enum.TryParse(entityType, true, out var parsedEntityType))
+ {
+ return TypedResults.Problem(
+ statusCode: StatusCodes.Status400BadRequest,
+ title: "invalid_entity_type",
+ detail: "Entity type must be one of: Finding, Vex, Advisory, Sbom, Evidence.");
+ }
+
+ var limit = ParseInt(httpContext.Request.Query["limit"]) ?? 100;
+ var changelog = await service.GetChangelogAsync(tenantId, parsedEntityType, entityId, limit, cancellationToken).ConfigureAwait(false);
+
+ IReadOnlyList response = changelog.Select(e => e.ToResponse()).ToList();
+ return TypedResults.Json(response);
+})
+.WithName("GetChangelog")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+// Staleness Check Endpoint
+app.MapGet("/v1/ledger/staleness", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var thresholdMinutes = ParseInt(httpContext.Request.Query["threshold_minutes"]) ?? 60;
+ var threshold = TimeSpan.FromMinutes(thresholdMinutes);
+
+ var result = await service.CheckStalenessAsync(tenantId, threshold, cancellationToken).ConfigureAwait(false);
+
+ return TypedResults.Json(result.ToResponse());
+})
+.WithName("CheckStaleness")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
+// Current Point Endpoint
+app.MapGet("/v1/ledger/current-point", async Task, ProblemHttpResult>> (
+ HttpContext httpContext,
+ SnapshotService service,
+ CancellationToken cancellationToken) =>
+{
+ if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
+ {
+ return tenantProblem!;
+ }
+
+ var point = await service.GetCurrentPointAsync(tenantId, cancellationToken).ConfigureAwait(false);
+ return TypedResults.Json(point.ToResponse());
+})
+.WithName("GetCurrentPoint")
+.RequireAuthorization(LedgerExportPolicy)
+.Produces(StatusCodes.Status200OK)
+.ProducesProblem(StatusCodes.Status400BadRequest);
+
app.Run();
static Created CreateCreatedResponse(LedgerEventRecord record)
@@ -738,3 +1323,8 @@ static bool? ParseBool(string value)
{
return bool.TryParse(value, out var result) ? result : null;
}
+
+static Guid? ParseGuid(string value)
+{
+ return Guid.TryParse(value, out var result) ? result : null;
+}
diff --git a/src/Findings/StellaOps.Findings.Ledger/Domain/LedgerEventConstants.cs b/src/Findings/StellaOps.Findings.Ledger/Domain/LedgerEventConstants.cs
index e4313ad07..74b0dedd5 100644
--- a/src/Findings/StellaOps.Findings.Ledger/Domain/LedgerEventConstants.cs
+++ b/src/Findings/StellaOps.Findings.Ledger/Domain/LedgerEventConstants.cs
@@ -18,6 +18,7 @@ public static class LedgerEventConstants
public const string EventEvidenceSnapshotLinked = "airgap.evidence_snapshot_linked";
public const string EventAirgapTimelineImpact = "airgap.timeline_impact";
public const string EventOrchestratorExportRecorded = "orchestrator.export_recorded";
+ public const string EventAttestationPointerLinked = "attestation.pointer_linked";
public static readonly ImmutableHashSet SupportedEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
EventFindingCreated,
@@ -33,7 +34,8 @@ public static class LedgerEventConstants
EventAirgapBundleImported,
EventEvidenceSnapshotLinked,
EventAirgapTimelineImpact,
- EventOrchestratorExportRecorded);
+ EventOrchestratorExportRecorded,
+ EventAttestationPointerLinked);
public static readonly ImmutableHashSet FindingEventTypes = ImmutableHashSet.Create(StringComparer.Ordinal,
EventFindingCreated,
diff --git a/src/Findings/StellaOps.Findings.Ledger/Domain/SnapshotModels.cs b/src/Findings/StellaOps.Findings.Ledger/Domain/SnapshotModels.cs
new file mode 100644
index 000000000..7f0ce9259
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger/Domain/SnapshotModels.cs
@@ -0,0 +1,281 @@
+namespace StellaOps.Findings.Ledger.Domain;
+
+///
+/// Represents a point-in-time snapshot of ledger state.
+///
+public sealed record LedgerSnapshot(
+ string TenantId,
+ Guid SnapshotId,
+ string? Label,
+ string? Description,
+ SnapshotStatus Status,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? ExpiresAt,
+ long SequenceNumber,
+ DateTimeOffset Timestamp,
+ SnapshotStatistics Statistics,
+ string? MerkleRoot,
+ string? DsseDigest,
+ Dictionary? Metadata = null);
+
+///
+/// Snapshot lifecycle status.
+///
+public enum SnapshotStatus
+{
+ Creating,
+ Available,
+ Exporting,
+ Expired,
+ Deleted
+}
+
+///
+/// Statistics for a snapshot.
+///
+public sealed record SnapshotStatistics(
+ long FindingsCount,
+ long VexStatementsCount,
+ long AdvisoriesCount,
+ long SbomsCount,
+ long EventsCount,
+ long SizeBytes);
+
+///
+/// Input for creating a snapshot.
+///
+public sealed record CreateSnapshotInput(
+ string TenantId,
+ string? Label = null,
+ string? Description = null,
+ DateTimeOffset? AtTimestamp = null,
+ long? AtSequence = null,
+ TimeSpan? ExpiresIn = null,
+ IReadOnlyList? IncludeEntityTypes = null,
+ bool Sign = false,
+ Dictionary? Metadata = null);
+
+///
+/// Result of creating a snapshot.
+///
+public sealed record CreateSnapshotResult(
+ bool Success,
+ LedgerSnapshot? Snapshot,
+ string? Error);
+
+///
+/// Entity types tracked in the ledger.
+///
+public enum EntityType
+{
+ Finding,
+ Vex,
+ Advisory,
+ Sbom,
+ Evidence
+}
+
+///
+/// Query point specification (timestamp or sequence).
+///
+public sealed record QueryPoint(
+ DateTimeOffset Timestamp,
+ long SequenceNumber,
+ Guid? SnapshotId = null);
+
+///
+/// Filters for time-travel queries.
+///
+public sealed record TimeQueryFilters(
+ string? Status = null,
+ decimal? SeverityMin = null,
+ decimal? SeverityMax = null,
+ string? PolicyVersion = null,
+ string? ArtifactId = null,
+ string? VulnId = null,
+ Dictionary? Labels = null);
+
+///
+/// Request for historical query.
+///
+public sealed record HistoricalQueryRequest(
+ string TenantId,
+ DateTimeOffset? AtTimestamp,
+ long? AtSequence,
+ Guid? SnapshotId,
+ EntityType EntityType,
+ TimeQueryFilters? Filters,
+ int PageSize = 500,
+ string? PageToken = null);
+
+///
+/// Response for historical query.
+///
+public sealed record HistoricalQueryResponse(
+ QueryPoint QueryPoint,
+ EntityType EntityType,
+ IReadOnlyList Items,
+ string? NextPageToken,
+ long TotalCount);
+
+///
+/// Request for replaying events.
+///
+public sealed record ReplayRequest(
+ string TenantId,
+ long? FromSequence = null,
+ long? ToSequence = null,
+ DateTimeOffset? FromTimestamp = null,
+ DateTimeOffset? ToTimestamp = null,
+ IReadOnlyList? ChainIds = null,
+ IReadOnlyList? EventTypes = null,
+ bool IncludePayload = true,
+ int PageSize = 1000);
+
+///
+/// Replayed event record.
+///
+public sealed record ReplayEvent(
+ Guid EventId,
+ long SequenceNumber,
+ Guid ChainId,
+ int ChainSequence,
+ string EventType,
+ DateTimeOffset OccurredAt,
+ DateTimeOffset RecordedAt,
+ string? ActorId,
+ string? ActorType,
+ string? ArtifactId,
+ string? FindingId,
+ string? PolicyVersion,
+ string EventHash,
+ string PreviousHash,
+ object? Payload);
+
+///
+/// Replay metadata.
+///
+public sealed record ReplayMetadata(
+ long FromSequence,
+ long ToSequence,
+ long EventsCount,
+ bool HasMore,
+ long ReplayDurationMs);
+
+///
+/// Request for computing diff between two points.
+///
+public sealed record DiffRequest(
+ string TenantId,
+ DiffPoint From,
+ DiffPoint To,
+ IReadOnlyList? EntityTypes = null,
+ bool IncludeUnchanged = false,
+ DiffOutputFormat OutputFormat = DiffOutputFormat.Summary);
+
+///
+/// Diff point specification.
+///
+public sealed record DiffPoint(
+ DateTimeOffset? Timestamp = null,
+ long? SequenceNumber = null,
+ Guid? SnapshotId = null);
+
+///
+/// Diff output format.
+///
+public enum DiffOutputFormat
+{
+ Summary,
+ Detailed,
+ Full
+}
+
+///
+/// Diff summary counts.
+///
+public sealed record DiffSummary(
+ int Added,
+ int Modified,
+ int Removed,
+ int Unchanged,
+ Dictionary? ByEntityType = null);
+
+///
+/// Diff counts per entity type.
+///
+public sealed record DiffCounts(int Added, int Modified, int Removed);
+
+///
+/// Individual diff entry.
+///
+public sealed record DiffEntry(
+ EntityType EntityType,
+ string EntityId,
+ DiffChangeType ChangeType,
+ object? FromState,
+ object? ToState,
+ IReadOnlyList? ChangedFields);
+
+///
+/// Type of change in a diff.
+///
+public enum DiffChangeType
+{
+ Added,
+ Modified,
+ Removed
+}
+
+///
+/// Diff response.
+///
+public sealed record DiffResponse(
+ QueryPoint FromPoint,
+ QueryPoint ToPoint,
+ DiffSummary Summary,
+ IReadOnlyList? Changes,
+ string? NextPageToken);
+
+///
+/// Changelog entry.
+///
+public sealed record ChangeLogEntry(
+ long SequenceNumber,
+ DateTimeOffset Timestamp,
+ EntityType EntityType,
+ string EntityId,
+ string EventType,
+ string? EventHash,
+ string? ActorId,
+ string? Summary);
+
+///
+/// Staleness check result.
+///
+public sealed record StalenessResult(
+ bool IsStale,
+ DateTimeOffset CheckedAt,
+ DateTimeOffset? LastEventAt,
+ TimeSpan StalenessThreshold,
+ TimeSpan? StalenessDuration,
+ Dictionary? ByEntityType = null);
+
+///
+/// Staleness per entity type.
+///
+public sealed record EntityStaleness(
+ bool IsStale,
+ DateTimeOffset? LastEventAt,
+ long EventsBehind);
+
+///
+/// Query parameters for listing snapshots.
+///
+public sealed record SnapshotListQuery(
+ string TenantId,
+ SnapshotStatus? Status = null,
+ DateTimeOffset? CreatedAfter = null,
+ DateTimeOffset? CreatedBefore = null,
+ int PageSize = 100,
+ string? PageToken = null);
diff --git a/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Attestation/AttestationPointerRecord.cs b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Attestation/AttestationPointerRecord.cs
new file mode 100644
index 000000000..58277efa6
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Attestation/AttestationPointerRecord.cs
@@ -0,0 +1,184 @@
+namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
+
+///
+/// Record representing an attestation pointer linking a finding to a verification report or attestation envelope.
+///
+public sealed record AttestationPointerRecord(
+ string TenantId,
+ Guid PointerId,
+ string FindingId,
+ AttestationType AttestationType,
+ AttestationRelationship Relationship,
+ AttestationRef AttestationRef,
+ VerificationResult? VerificationResult,
+ DateTimeOffset CreatedAt,
+ string CreatedBy,
+ Dictionary? Metadata = null,
+ Guid? LedgerEventId = null);
+
+///
+/// Type of attestation being pointed to.
+///
+public enum AttestationType
+{
+ VerificationReport,
+ DsseEnvelope,
+ SlsaProvenance,
+ VexAttestation,
+ SbomAttestation,
+ ScanAttestation,
+ PolicyAttestation,
+ ApprovalAttestation
+}
+
+///
+/// Semantic relationship between finding and attestation.
+///
+public enum AttestationRelationship
+{
+ VerifiedBy,
+ AttestedBy,
+ SignedBy,
+ ApprovedBy,
+ DerivedFrom
+}
+
+///
+/// Reference to an attestation artifact.
+///
+public sealed record AttestationRef(
+ string Digest,
+ Guid? AttestationId = null,
+ string? StorageUri = null,
+ string? PayloadType = null,
+ string? PredicateType = null,
+ IReadOnlyList? SubjectDigests = null,
+ SignerInfo? SignerInfo = null,
+ RekorEntryRef? RekorEntry = null);
+
+///
+/// Information about the attestation signer.
+///
+public sealed record SignerInfo(
+ string? KeyId = null,
+ string? Issuer = null,
+ string? Subject = null,
+ IReadOnlyList? CertificateChain = null,
+ DateTimeOffset? SignedAt = null);
+
+///
+/// Reference to Rekor transparency log entry.
+///
+public sealed record RekorEntryRef(
+ long? LogIndex = null,
+ string? LogId = null,
+ string? Uuid = null,
+ long? IntegratedTime = null);
+
+///
+/// Result of attestation verification.
+///
+public sealed record VerificationResult(
+ bool Verified,
+ DateTimeOffset VerifiedAt,
+ string? Verifier = null,
+ string? VerifierVersion = null,
+ string? PolicyRef = null,
+ IReadOnlyList? Checks = null,
+ IReadOnlyList? Warnings = null,
+ IReadOnlyList? Errors = null);
+
+///
+/// Individual verification check result.
+///
+public sealed record VerificationCheck(
+ VerificationCheckType CheckType,
+ bool Passed,
+ string? Details = null,
+ Dictionary? Evidence = null);
+
+///
+/// Type of verification check performed.
+///
+public enum VerificationCheckType
+{
+ SignatureValid,
+ CertificateValid,
+ CertificateNotExpired,
+ CertificateNotRevoked,
+ RekorEntryValid,
+ TimestampValid,
+ PolicyMet,
+ IdentityVerified,
+ IssuerTrusted
+}
+
+///
+/// Input for creating an attestation pointer.
+///
+public sealed record AttestationPointerInput(
+ string TenantId,
+ string FindingId,
+ AttestationType AttestationType,
+ AttestationRelationship Relationship,
+ AttestationRef AttestationRef,
+ VerificationResult? VerificationResult = null,
+ string? CreatedBy = null,
+ Dictionary? Metadata = null);
+
+///
+/// Result of creating an attestation pointer.
+///
+public sealed record AttestationPointerResult(
+ bool Success,
+ Guid? PointerId,
+ Guid? LedgerEventId,
+ string? Error);
+
+///
+/// Summary of attestations for a finding.
+///
+public sealed record FindingAttestationSummary(
+ string FindingId,
+ int AttestationCount,
+ int VerifiedCount,
+ DateTimeOffset? LatestAttestation,
+ IReadOnlyList AttestationTypes,
+ OverallVerificationStatus OverallVerificationStatus);
+
+///
+/// Overall verification status for a finding's attestations.
+///
+public enum OverallVerificationStatus
+{
+ AllVerified,
+ PartiallyVerified,
+ NoneVerified,
+ NoAttestations
+}
+
+///
+/// Query parameters for searching attestation pointers.
+///
+public sealed record AttestationPointerQuery(
+ string TenantId,
+ IReadOnlyList? FindingIds = null,
+ IReadOnlyList? AttestationTypes = null,
+ AttestationVerificationFilter? VerificationStatus = null,
+ DateTimeOffset? CreatedAfter = null,
+ DateTimeOffset? CreatedBefore = null,
+ string? SignerIdentity = null,
+ string? PredicateType = null,
+ int Limit = 100,
+ int Offset = 0);
+
+///
+/// Filter for verification status.
+///
+public enum AttestationVerificationFilter
+{
+ Any,
+ Verified,
+ Unverified,
+ Failed
+}
diff --git a/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Attestation/IAttestationPointerRepository.cs b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Attestation/IAttestationPointerRepository.cs
new file mode 100644
index 000000000..af3661cf5
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Attestation/IAttestationPointerRepository.cs
@@ -0,0 +1,97 @@
+namespace StellaOps.Findings.Ledger.Infrastructure.Attestation;
+
+///
+/// Repository for managing attestation pointers linking findings to verification reports and attestation envelopes.
+///
+public interface IAttestationPointerRepository
+{
+ ///
+ /// Inserts a new attestation pointer.
+ ///
+ Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken);
+
+ ///
+ /// Gets an attestation pointer by ID.
+ ///
+ Task GetByIdAsync(
+ string tenantId,
+ Guid pointerId,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Gets all attestation pointers for a finding.
+ ///
+ Task> GetByFindingIdAsync(
+ string tenantId,
+ string findingId,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Gets attestation pointers by attestation digest.
+ ///
+ Task> GetByDigestAsync(
+ string tenantId,
+ string digest,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Searches attestation pointers based on query parameters.
+ ///
+ Task> SearchAsync(
+ AttestationPointerQuery query,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Gets attestation summary for a finding.
+ ///
+ Task GetSummaryAsync(
+ string tenantId,
+ string findingId,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Gets attestation summaries for multiple findings.
+ ///
+ Task> GetSummariesAsync(
+ string tenantId,
+ IReadOnlyList findingIds,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Checks if an attestation pointer already exists (for idempotency).
+ ///
+ Task ExistsAsync(
+ string tenantId,
+ string findingId,
+ string digest,
+ AttestationType attestationType,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Updates the verification result for an attestation pointer.
+ ///
+ Task UpdateVerificationResultAsync(
+ string tenantId,
+ Guid pointerId,
+ VerificationResult verificationResult,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Gets the count of attestation pointers for a finding.
+ ///
+ Task GetCountAsync(
+ string tenantId,
+ string findingId,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Gets findings that have attestations matching the criteria.
+ ///
+ Task> GetFindingIdsWithAttestationsAsync(
+ string tenantId,
+ AttestationVerificationFilter? verificationFilter,
+ IReadOnlyList? attestationTypes,
+ int limit,
+ int offset,
+ CancellationToken cancellationToken);
+}
diff --git a/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Postgres/PostgresAttestationPointerRepository.cs b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Postgres/PostgresAttestationPointerRepository.cs
new file mode 100644
index 000000000..6b254ad23
--- /dev/null
+++ b/src/Findings/StellaOps.Findings.Ledger/Infrastructure/Postgres/PostgresAttestationPointerRepository.cs
@@ -0,0 +1,668 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+using NpgsqlTypes;
+using StellaOps.Findings.Ledger.Infrastructure.Attestation;
+
+namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
+
+///
+/// Postgres-backed repository for attestation pointers.
+///
+public sealed class PostgresAttestationPointerRepository : IAttestationPointerRepository
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ WriteIndented = false
+ };
+
+ private readonly LedgerDataSource _dataSource;
+ private readonly ILogger _logger;
+
+ public PostgresAttestationPointerRepository(
+ LedgerDataSource dataSource,
+ ILogger logger)
+ {
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task InsertAsync(AttestationPointerRecord record, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+
+ const string sql = """
+ INSERT INTO ledger_attestation_pointers (
+ tenant_id, pointer_id, finding_id, attestation_type, relationship,
+ attestation_ref, verification_result, created_at, created_by,
+ metadata, ledger_event_id
+ ) VALUES (
+ @tenant_id, @pointer_id, @finding_id, @attestation_type, @relationship,
+ @attestation_ref::jsonb, @verification_result::jsonb, @created_at, @created_by,
+ @metadata::jsonb, @ledger_event_id
+ )
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ record.TenantId, "attestation_pointer_write", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", record.TenantId);
+ command.Parameters.AddWithValue("pointer_id", record.PointerId);
+ command.Parameters.AddWithValue("finding_id", record.FindingId);
+ command.Parameters.AddWithValue("attestation_type", record.AttestationType.ToString());
+ command.Parameters.AddWithValue("relationship", record.Relationship.ToString());
+ command.Parameters.AddWithValue("attestation_ref", JsonSerializer.Serialize(record.AttestationRef, JsonOptions));
+ command.Parameters.AddWithValue("verification_result",
+ record.VerificationResult is not null
+ ? JsonSerializer.Serialize(record.VerificationResult, JsonOptions)
+ : DBNull.Value);
+ command.Parameters.AddWithValue("created_at", record.CreatedAt);
+ command.Parameters.AddWithValue("created_by", record.CreatedBy);
+ command.Parameters.AddWithValue("metadata",
+ record.Metadata is not null
+ ? JsonSerializer.Serialize(record.Metadata, JsonOptions)
+ : DBNull.Value);
+ command.Parameters.AddWithValue("ledger_event_id",
+ record.LedgerEventId.HasValue ? record.LedgerEventId.Value : DBNull.Value);
+
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+
+ _logger.LogDebug(
+ "Inserted attestation pointer {PointerId} for finding {FindingId} with type {AttestationType}",
+ record.PointerId, record.FindingId, record.AttestationType);
+ }
+
+ public async Task GetByIdAsync(
+ string tenantId,
+ Guid pointerId,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+
+ const string sql = """
+ SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
+ attestation_ref, verification_result, created_at, created_by,
+ metadata, ledger_event_id
+ FROM ledger_attestation_pointers
+ WHERE tenant_id = @tenant_id AND pointer_id = @pointer_id
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("pointer_id", pointerId);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+
+ if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ return ReadRecord(reader);
+ }
+
+ return null;
+ }
+
+ public async Task> GetByFindingIdAsync(
+ string tenantId,
+ string findingId,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
+
+ const string sql = """
+ SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
+ attestation_ref, verification_result, created_at, created_by,
+ metadata, ledger_event_id
+ FROM ledger_attestation_pointers
+ WHERE tenant_id = @tenant_id AND finding_id = @finding_id
+ ORDER BY created_at DESC
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("finding_id", findingId);
+
+ return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task> GetByDigestAsync(
+ string tenantId,
+ string digest,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(digest);
+
+ const string sql = """
+ SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
+ attestation_ref, verification_result, created_at, created_by,
+ metadata, ledger_event_id
+ FROM ledger_attestation_pointers
+ WHERE tenant_id = @tenant_id
+ AND attestation_ref->>'digest' = @digest
+ ORDER BY created_at DESC
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ tenantId, "attestation_pointer_read", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("digest", digest);
+
+ return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task> SearchAsync(
+ AttestationPointerQuery query,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
+
+ var sqlBuilder = new StringBuilder("""
+ SELECT tenant_id, pointer_id, finding_id, attestation_type, relationship,
+ attestation_ref, verification_result, created_at, created_by,
+ metadata, ledger_event_id
+ FROM ledger_attestation_pointers
+ WHERE tenant_id = @tenant_id
+ """);
+
+ var parameters = new List
+ {
+ new("tenant_id", query.TenantId) { NpgsqlDbType = NpgsqlDbType.Text }
+ };
+
+ if (query.FindingIds is { Count: > 0 })
+ {
+ sqlBuilder.Append(" AND finding_id = ANY(@finding_ids)");
+ parameters.Add(new NpgsqlParameter("finding_ids", query.FindingIds.ToArray()));
+ }
+
+ if (query.AttestationTypes is { Count: > 0 })
+ {
+ sqlBuilder.Append(" AND attestation_type = ANY(@attestation_types)");
+ parameters.Add(new NpgsqlParameter("attestation_types",
+ query.AttestationTypes.Select(t => t.ToString()).ToArray()));
+ }
+
+ if (query.VerificationStatus.HasValue && query.VerificationStatus.Value != AttestationVerificationFilter.Any)
+ {
+ sqlBuilder.Append(query.VerificationStatus.Value switch
+ {
+ AttestationVerificationFilter.Verified =>
+ " AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = true",
+ AttestationVerificationFilter.Unverified =>
+ " AND verification_result IS NULL",
+ AttestationVerificationFilter.Failed =>
+ " AND verification_result IS NOT NULL AND (verification_result->>'verified')::boolean = false",
+ _ => ""
+ });
+ }
+
+ if (query.CreatedAfter.HasValue)
+ {
+ sqlBuilder.Append(" AND created_at >= @created_after");
+ parameters.Add(new NpgsqlParameter("created_after", query.CreatedAfter.Value)
+ {
+ NpgsqlDbType = NpgsqlDbType.TimestampTz
+ });
+ }
+
+ if (query.CreatedBefore.HasValue)
+ {
+ sqlBuilder.Append(" AND created_at <= @created_before");
+ parameters.Add(new NpgsqlParameter("created_before", query.CreatedBefore.Value)
+ {
+ NpgsqlDbType = NpgsqlDbType.TimestampTz
+ });
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.SignerIdentity))
+ {
+ sqlBuilder.Append(" AND attestation_ref->'signer_info'->>'subject' = @signer_identity");
+ parameters.Add(new NpgsqlParameter("signer_identity", query.SignerIdentity));
+ }
+
+ if (!string.IsNullOrWhiteSpace(query.PredicateType))
+ {
+ sqlBuilder.Append(" AND attestation_ref->>'predicate_type' = @predicate_type");
+ parameters.Add(new NpgsqlParameter("predicate_type", query.PredicateType));
+ }
+
+ sqlBuilder.Append(" ORDER BY created_at DESC");
+ sqlBuilder.Append(" LIMIT @limit OFFSET @offset");
+ parameters.Add(new NpgsqlParameter("limit", query.Limit));
+ parameters.Add(new NpgsqlParameter("offset", query.Offset));
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ query.TenantId, "attestation_pointer_search", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sqlBuilder.ToString(), connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+ command.Parameters.AddRange(parameters.ToArray());
+
+ return await ReadRecordsAsync(command, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task GetSummaryAsync(
+ string tenantId,
+ string findingId,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
+
+ const string sql = """
+ SELECT
+ COUNT(*) as total_count,
+ COUNT(*) FILTER (WHERE verification_result IS NOT NULL
+ AND (verification_result->>'verified')::boolean = true) as verified_count,
+ MAX(created_at) as latest_attestation,
+ array_agg(DISTINCT attestation_type) as attestation_types
+ FROM ledger_attestation_pointers
+ WHERE tenant_id = @tenant_id AND finding_id = @finding_id
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ tenantId, "attestation_pointer_summary", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("finding_id", findingId);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+
+ if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var totalCount = reader.GetInt32(0);
+ var verifiedCount = reader.GetInt32(1);
+ var latestAttestation = reader.IsDBNull(2)
+ ? (DateTimeOffset?)null
+ : reader.GetFieldValue(2);
+ var attestationTypesRaw = reader.IsDBNull(3)
+ ? Array.Empty()
+ : reader.GetFieldValue(3);
+
+ var attestationTypes = attestationTypesRaw
+ .Where(t => Enum.TryParse(t, out _))
+ .Select(t => Enum.Parse(t))
+ .ToList();
+
+ var overallStatus = totalCount switch
+ {
+ 0 => OverallVerificationStatus.NoAttestations,
+ _ when verifiedCount == totalCount => OverallVerificationStatus.AllVerified,
+ _ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
+ _ => OverallVerificationStatus.NoneVerified
+ };
+
+ return new FindingAttestationSummary(
+ findingId,
+ totalCount,
+ verifiedCount,
+ latestAttestation,
+ attestationTypes,
+ overallStatus);
+ }
+
+ return new FindingAttestationSummary(
+ findingId,
+ 0,
+ 0,
+ null,
+ Array.Empty(),
+ OverallVerificationStatus.NoAttestations);
+ }
+
+ public async Task> GetSummariesAsync(
+ string tenantId,
+ IReadOnlyList findingIds,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ ArgumentNullException.ThrowIfNull(findingIds);
+
+ if (findingIds.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ const string sql = """
+ SELECT
+ finding_id,
+ COUNT(*) as total_count,
+ COUNT(*) FILTER (WHERE verification_result IS NOT NULL
+ AND (verification_result->>'verified')::boolean = true) as verified_count,
+ MAX(created_at) as latest_attestation,
+ array_agg(DISTINCT attestation_type) as attestation_types
+ FROM ledger_attestation_pointers
+ WHERE tenant_id = @tenant_id AND finding_id = ANY(@finding_ids)
+ GROUP BY finding_id
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ tenantId, "attestation_pointer_summaries", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("finding_ids", findingIds.ToArray());
+
+ var results = new List();
+ var foundIds = new HashSet(StringComparer.Ordinal);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var fid = reader.GetString(0);
+ foundIds.Add(fid);
+
+ var totalCount = reader.GetInt32(1);
+ var verifiedCount = reader.GetInt32(2);
+ var latestAttestation = reader.IsDBNull(3)
+ ? (DateTimeOffset?)null
+ : reader.GetFieldValue(3);
+ var attestationTypesRaw = reader.IsDBNull(4)
+ ? Array.Empty()
+ : reader.GetFieldValue(4);
+
+ var attestationTypes = attestationTypesRaw
+ .Where(t => Enum.TryParse(t, out _))
+ .Select(t => Enum.Parse(t))
+ .ToList();
+
+ var overallStatus = totalCount switch
+ {
+ 0 => OverallVerificationStatus.NoAttestations,
+ _ when verifiedCount == totalCount => OverallVerificationStatus.AllVerified,
+ _ when verifiedCount > 0 => OverallVerificationStatus.PartiallyVerified,
+ _ => OverallVerificationStatus.NoneVerified
+ };
+
+ results.Add(new FindingAttestationSummary(
+ fid,
+ totalCount,
+ verifiedCount,
+ latestAttestation,
+ attestationTypes,
+ overallStatus));
+ }
+
+ // Add empty summaries for findings without attestations
+ foreach (var fid in findingIds.Where(f => !foundIds.Contains(f)))
+ {
+ results.Add(new FindingAttestationSummary(
+ fid,
+ 0,
+ 0,
+ null,
+ Array.Empty(),
+ OverallVerificationStatus.NoAttestations));
+ }
+
+ return results;
+ }
+
+ public async Task ExistsAsync(
+ string tenantId,
+ string findingId,
+ string digest,
+ AttestationType attestationType,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(digest);
+
+ const string sql = """
+ SELECT EXISTS(
+ SELECT 1 FROM ledger_attestation_pointers
+ WHERE tenant_id = @tenant_id
+ AND finding_id = @finding_id
+ AND attestation_ref->>'digest' = @digest
+ AND attestation_type = @attestation_type
+ )
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ tenantId, "attestation_pointer_exists", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("finding_id", findingId);
+ command.Parameters.AddWithValue("digest", digest);
+ command.Parameters.AddWithValue("attestation_type", attestationType.ToString());
+
+ var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ return result is true;
+ }
+
+ public async Task UpdateVerificationResultAsync(
+ string tenantId,
+ Guid pointerId,
+ VerificationResult verificationResult,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ ArgumentNullException.ThrowIfNull(verificationResult);
+
+ const string sql = """
+ UPDATE ledger_attestation_pointers
+ SET verification_result = @verification_result::jsonb
+ WHERE tenant_id = @tenant_id AND pointer_id = @pointer_id
+ """;
+
+ await using var connection = await _dataSource.OpenConnectionAsync(
+ tenantId, "attestation_pointer_update", cancellationToken).ConfigureAwait(false);
+
+ await using var command = new NpgsqlCommand(sql, connection)
+ {
+ CommandTimeout = _dataSource.CommandTimeoutSeconds
+ };
+
+ command.Parameters.AddWithValue("tenant_id", tenantId);
+ command.Parameters.AddWithValue("pointer_id", pointerId);
+ command.Parameters.AddWithValue("verification_result",
+ JsonSerializer.Serialize(verificationResult, JsonOptions));
+
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+
+ _logger.LogDebug(
+ "Updated verification result for attestation pointer {PointerId}, verified={Verified}",
+ pointerId, verificationResult.Verified);
+ }
+
+ public async Task