work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View File

@@ -1,19 +1,17 @@
<Solution> <Solution>
<Folder Name="/src/" /> <Folder Name="/src/" />
<Folder Name="/src/Gateway/">
<Project Path="src/Gateway/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj" />
</Folder>
<Folder Name="/src/__Libraries/"> <Folder Name="/src/__Libraries/">
<Project Path="src/__Libraries/StellaOps.Microservice.SourceGen/StellaOps.Microservice.SourceGen.csproj" /> <Project Path="src/__Libraries/StellaOps.Microservice.SourceGen/StellaOps.Microservice.SourceGen.csproj" />
<Project Path="src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj" /> <Project Path="src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj" />
<Project Path="src/__Libraries/StellaOps.Router.Common/StellaOps.Router.Common.csproj" /> <Project Path="src/__Libraries/StellaOps.Router.Common/StellaOps.Router.Common.csproj" />
<Project Path="src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj" /> <Project Path="src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj" />
<Project Path="src/__Libraries/StellaOps.Router.Gateway/StellaOps.Router.Gateway.csproj" />
<Project Path="src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj" /> <Project Path="src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj" />
<Project Path="tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj" /> <Project Path="tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj" />
<Project Path="tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj" /> <Project Path="tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj" />
<Project Path="tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj" />
<Project Path="tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj" /> <Project Path="tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -0,0 +1,42 @@
# Scanner FN-Drift Alert Rules
# SLO alerts for false-negative drift thresholds (30-day rolling window)
groups:
- name: scanner-fn-drift
interval: 30s
rules:
- alert: ScannerFnDriftWarning
expr: scanner_fn_drift_percent > 1.0
for: 5m
labels:
severity: warning
service: scanner
slo: fn-drift
annotations:
summary: "Scanner FN-Drift rate above warning threshold"
description: "FN-Drift is {{ $value | humanizePercentage }} (> 1.0%) over the 30-day rolling window."
runbook_url: "https://docs.stellaops.io/runbooks/scanner/fn-drift-warning"
- alert: ScannerFnDriftCritical
expr: scanner_fn_drift_percent > 2.5
for: 5m
labels:
severity: critical
service: scanner
slo: fn-drift
annotations:
summary: "Scanner FN-Drift rate above critical threshold"
description: "FN-Drift is {{ $value | humanizePercentage }} (> 2.5%) over the 30-day rolling window."
runbook_url: "https://docs.stellaops.io/runbooks/scanner/fn-drift-critical"
- alert: ScannerFnDriftEngineViolation
expr: scanner_fn_drift_cause_engine > 0
for: 1m
labels:
severity: page
service: scanner
slo: determinism
annotations:
summary: "Engine-caused FN drift detected (determinism violation)"
description: "Engine-caused FN drift count is {{ $value }} (> 0). This indicates non-feed, non-policy changes affecting outcomes."
runbook_url: "https://docs.stellaops.io/runbooks/scanner/fn-drift-engine-violation"

View File

@@ -2,7 +2,7 @@
**Version:** 1.0.0 **Version:** 1.0.0
**Status:** DRAFT **Status:** DRAFT
**Last Updated:** 2025-12-15 **Last Updated:** 2025-12-17
--- ---
@@ -44,9 +44,14 @@ This document specifies the PostgreSQL database design for StellaOps control-pla
| `policy` | Policy | Policy packs, rules, risk profiles, evaluations | | `policy` | Policy | Policy packs, rules, risk profiles, evaluations |
| `packs` | PacksRegistry | Package attestations, mirrors, lifecycle | | `packs` | PacksRegistry | Package attestations, mirrors, lifecycle |
| `issuer` | IssuerDirectory | Trust anchors, issuer keys, certificates | | `issuer` | IssuerDirectory | Trust anchors, issuer keys, certificates |
| `proofchain` | Attestor | Content-addressed proof/evidence chain (entries, DSSE envelopes, spines, trust anchors, Rekor) |
| `unknowns` | Unknowns | Bitemporal ambiguity tracking for scan gaps | | `unknowns` | Unknowns | Bitemporal ambiguity tracking for scan gaps |
| `audit` | Shared | Cross-cutting audit log (optional) | | `audit` | Shared | Cross-cutting audit log (optional) |
**ProofChain references:**
- DDL migration: `src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/20251214000001_AddProofChainSchema.sql`
- Perf report: `docs/db/reports/proofchain-schema-perf-2025-12-17.md`
### 2.3 Multi-Tenancy Model ### 2.3 Multi-Tenancy Model
**Strategy:** Single database, single schema set, `tenant_id` column on all tenant-scoped tables with **mandatory Row-Level Security (RLS)**. **Strategy:** Single database, single schema set, `tenant_id` column on all tenant-scoped tables with **mandatory Row-Level Security (RLS)**.

View File

@@ -0,0 +1,127 @@
# ProofChain schema performance report (2025-12-17)
## Environment
- Postgres image: `postgres:16`
- DB: `proofchain_perf`
- Port: `54329`
- Host: `localhost`
## Dataset
- Source: `src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Perf/seed.sql`
- Rows:
- `trust_anchors`: 50
- `sbom_entries`: 20000
- `dsse_envelopes`: 60000
- `spines`: 20000
- `rekor_entries`: 2000
## Query Output
```text
Timing is on.
trust_anchors | sbom_entries | dsse_envelopes | spines | rekor_entries
---------------+--------------+----------------+--------+---------------
50 | 20000 | 60000 | 20000 | 2000
(1 row)
Time: 18.788 ms
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using uq_sbom_entry on sbom_entries (cost=0.41..8.44 rows=1 width=226) (actual time=0.024..0.024 rows=1 loops=1)
Index Cond: (((bom_digest)::text = 'd2cb2e2d7955252437da988dd4484f1dfcde81750ce0175d9fb9a85134a8de9a'::text) AND (purl = format('pkg:npm/vendor-%02s/pkg-%05s'::text, 1, 1)) AND (version = '1.0.1'::text))
Buffers: shared hit=4
Planning:
Buffers: shared hit=24
Planning Time: 0.431 ms
Execution Time: 0.032 ms
(7 rows)
Time: 1.119 ms
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=173.99..174.13 rows=56 width=80) (actual time=0.331..0.340 rows=100 loops=1)
Buffers: shared hit=8
-> Sort (cost=173.99..174.13 rows=56 width=80) (actual time=0.330..0.335 rows=100 loops=1)
Sort Key: purl
Sort Method: quicksort Memory: 38kB
Buffers: shared hit=8
-> Bitmap Heap Scan on sbom_entries (cost=4.72..172.37 rows=56 width=80) (actual time=0.019..0.032 rows=100 loops=1)
Recheck Cond: ((bom_digest)::text = 'd2cb2e2d7955252437da988dd4484f1dfcde81750ce0175d9fb9a85134a8de9a'::text)
Heap Blocks: exact=3
Buffers: shared hit=5
-> Bitmap Index Scan on idx_sbom_entries_bom_digest (cost=0.00..4.71 rows=56 width=0) (actual time=0.015..0.015 rows=100 loops=1)
Index Cond: ((bom_digest)::text = 'd2cb2e2d7955252437da988dd4484f1dfcde81750ce0175d9fb9a85134a8de9a'::text)
Buffers: shared hit=2
Planning:
Buffers: shared hit=12 read=1
Planning Time: 0.149 ms
Execution Time: 0.355 ms
(17 rows)
Time: 0.867 ms
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using idx_dsse_entry_predicate on dsse_envelopes (cost=0.41..8.43 rows=1 width=226) (actual time=0.008..0.009 rows=1 loops=1)
Index Cond: ((entry_id = '924258f2-921e-9694-13a4-400abfdf00d6'::uuid) AND (predicate_type = 'evidence.stella/v1'::text))
Buffers: shared hit=4
Planning:
Buffers: shared hit=23
Planning Time: 0.150 ms
Execution Time: 0.014 ms
(7 rows)
Time: 0.388 ms
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
Index Scan using idx_spines_bundle on spines (cost=0.41..8.43 rows=1 width=194) (actual time=0.016..0.017 rows=1 loops=1)
Index Cond: ((bundle_id)::text = '2f9ef44d93b4520b2296d5b73bd1cc87156a304c757feb4c78926452db61abf8'::text)
Buffers: shared hit=4
Planning Time: 0.096 ms
Execution Time: 0.025 ms
(5 rows)
Time: 0.318 ms
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on rekor_entries (cost=4.34..27.60 rows=8 width=186) (actual time=0.024..0.024 rows=0 loops=1)
Recheck Cond: (log_index = 10)
Buffers: shared hit=5
-> Bitmap Index Scan on idx_rekor_log_index (cost=0.00..4.34 rows=8 width=0) (actual time=0.023..0.023 rows=0 loops=1)
Index Cond: (log_index = 10)
Buffers: shared hit=5
Planning:
Buffers: shared hit=5
Planning Time: 0.097 ms
Execution Time: 0.040 ms
(10 rows)
Time: 0.335 ms
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=637.30..637.30 rows=1 width=226) (actual time=0.649..0.660 rows=100 loops=1)
Buffers: shared hit=405
-> Sort (cost=637.30..637.30 rows=1 width=226) (actual time=0.648..0.653 rows=100 loops=1)
Sort Key: e.purl
Sort Method: quicksort Memory: 50kB
Buffers: shared hit=405
-> Nested Loop (cost=5.13..637.29 rows=1 width=226) (actual time=0.074..0.385 rows=100 loops=1)
Buffers: shared hit=405
-> Bitmap Heap Scan on sbom_entries e (cost=4.72..172.37 rows=56 width=48) (actual time=0.061..0.071 rows=100 loops=1)
Recheck Cond: ((bom_digest)::text = 'd2cb2e2d7955252437da988dd4484f1dfcde81750ce0175d9fb9a85134a8de9a'::text)
Heap Blocks: exact=3
Buffers: shared hit=5
-> Bitmap Index Scan on idx_sbom_entries_bom_digest (cost=0.00..4.71 rows=56 width=0) (actual time=0.057..0.057 rows=100 loops=1)
Index Cond: ((bom_digest)::text = 'd2cb2e2d7955252437da988dd4484f1dfcde81750ce0175d9fb9a85134a8de9a'::text)
Buffers: shared hit=2
-> Index Scan using idx_dsse_entry_predicate on dsse_envelopes d (cost=0.41..8.29 rows=1 width=194) (actual time=0.003..0.003 rows=1 loops=100)
Index Cond: ((entry_id = e.entry_id) AND (predicate_type = 'evidence.stella/v1'::text))
Buffers: shared hit=400
Planning:
Buffers: shared hit=114
Planning Time: 0.469 ms
Execution Time: 0.691 ms
(22 rows)
Time: 1.643 ms
```

View File

@@ -72,12 +72,12 @@ stellaops verify offline \
| 2 | T2 | DONE | Implemented `OfflineCommandGroup` and wired into `CommandFactory`. | DevEx/CLI Guild | Create `OfflineCommandGroup` class. | | 2 | T2 | DONE | Implemented `OfflineCommandGroup` and wired into `CommandFactory`. | DevEx/CLI Guild | Create `OfflineCommandGroup` class. |
| 3 | T3 | DONE | Implemented `offline import` with manifest/hash validation, monotonicity checks, and quarantine hooks. | DevEx/CLI Guild | Implement `offline import` command (core import flow). | | 3 | T3 | DONE | Implemented `offline import` with manifest/hash validation, monotonicity checks, and quarantine hooks. | DevEx/CLI Guild | Implement `offline import` command (core import flow). |
| 4 | T4 | DONE | Implemented `--verify-dsse` via `DsseVerifier` (requires `--trust-root`) and added tests. | DevEx/CLI Guild | Add `--verify-dsse` flag handler. | | 4 | T4 | DONE | Implemented `--verify-dsse` via `DsseVerifier` (requires `--trust-root`) and added tests. | DevEx/CLI Guild | Add `--verify-dsse` flag handler. |
| 5 | T5 | BLOCKED | Needs offline Rekor inclusion proof verification contract/library; current implementation only validates receipt structure. | DevEx/CLI Guild | Add `--verify-rekor` flag handler. | | 5 | T5 | DOING | Implement offline Rekor receipt inclusion proof + checkpoint signature verification per `docs/product-advisories/14-Dec-2025 - Rekor Integration Technical Reference.md` §13. | DevEx/CLI Guild | Add `--verify-rekor` flag handler. |
| 6 | T6 | DONE | Implemented deterministic trust-root loading (`--trust-root`). | DevEx/CLI Guild | Add `--trust-root` option. | | 6 | T6 | DONE | Implemented deterministic trust-root loading (`--trust-root`). | DevEx/CLI Guild | Add `--trust-root` option. |
| 7 | T7 | DONE | Enforced `--force-reason` when forcing activation and persisted justification. | DevEx/CLI Guild | Add `--force-activate` flag. | | 7 | T7 | DONE | Enforced `--force-reason` when forcing activation and persisted justification. | DevEx/CLI Guild | Add `--force-activate` flag. |
| 8 | T8 | DONE | Implemented `offline status` with table/json outputs. | DevEx/CLI Guild | Implement `offline status` command. | | 8 | T8 | DONE | Implemented `offline status` with table/json outputs. | DevEx/CLI Guild | Implement `offline status` command. |
| 9 | T9 | BLOCKED | Needs policy/verification contract (exit code mapping + evaluation semantics) before implementing `verify offline`. | DevEx/CLI Guild | Implement `verify offline` command. | | 9 | T9 | DOING | Implement `verify offline` using the policy schema in `docs/product-advisories/14-Dec-2025 - Offline and Air-Gap Technical Reference.md` §4 plus deterministic evidence reconciliation outputs. | DevEx/CLI Guild | Implement `verify offline` command. |
| 10 | T10 | BLOCKED | Depends on the `verify offline` policy schema/loader contract (YAML/JSON canonicalization rules). | DevEx/CLI Guild | Add `--policy` option parser. | | 10 | T10 | DOING | Add YAML+JSON policy loader with deterministic parsing/canonicalization rules; share with AirGap reconciliation. | DevEx/CLI Guild | Add `--policy` option parser. |
| 11 | T11 | DONE | Standardized `--output table|json` formatting for offline verbs. | DevEx/CLI Guild | Create output formatters (table, json). | | 11 | T11 | DONE | Standardized `--output table|json` formatting for offline verbs. | DevEx/CLI Guild | Create output formatters (table, json). |
| 12 | T12 | DONE | Added progress reporting for bundle hashing when bundle size exceeds threshold. | DevEx/CLI Guild | Implement progress reporting. | | 12 | T12 | DONE | Added progress reporting for bundle hashing when bundle size exceeds threshold. | DevEx/CLI Guild | Implement progress reporting. |
| 13 | T13 | DONE | Implemented offline exit codes (`OfflineExitCodes`). | DevEx/CLI Guild | Add exit code standardization. | | 13 | T13 | DONE | Implemented offline exit codes (`OfflineExitCodes`). | DevEx/CLI Guild | Add exit code standardization. |
@@ -682,5 +682,6 @@ public static class OfflineExitCodes
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-12-17 | Unblocked T5/T9/T10 by adopting the published offline policy schema (A12) and Rekor receipt contract (Rekor Technical Reference §13); started implementation of offline Rekor inclusion proof verification and `verify offline`. | Agent |
| 2025-12-15 | Implemented `offline import/status` (+ exit codes, state storage, quarantine hooks), added docs and tests; validated with `dotnet test src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj -c Release`; marked T5/T9/T10 BLOCKED pending verifier/policy contracts. | DevEx/CLI | | 2025-12-15 | Implemented `offline import/status` (+ exit codes, state storage, quarantine hooks), added docs and tests; validated with `dotnet test src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj -c Release`; marked T5/T9/T10 BLOCKED pending verifier/policy contracts. | DevEx/CLI |
| 2025-12-15 | Normalised sprint file to standard template; set T1 to DOING. | Planning · DevEx/CLI | | 2025-12-15 | Normalised sprint file to standard template; set T1 to DOING. | Planning · DevEx/CLI |

View File

@@ -3,7 +3,7 @@
**Epic:** Time-to-First-Signal (TTFS) Implementation **Epic:** Time-to-First-Signal (TTFS) Implementation
**Module:** Web UI **Module:** Web UI
**Working Directory:** `src/Web/StellaOps.Web/src/app/` **Working Directory:** `src/Web/StellaOps.Web/src/app/`
**Status:** BLOCKED **Status:** DOING
**Created:** 2025-12-14 **Created:** 2025-12-14
**Target Completion:** TBD **Target Completion:** TBD
**Depends On:** SPRINT_0339_0001_0001 (First Signal API) **Depends On:** SPRINT_0339_0001_0001 (First Signal API)
@@ -49,15 +49,15 @@ This sprint implements the `FirstSignalCard` Angular component that displays the
| T6 | Create FirstSignalCard styles | — | DONE | `src/Web/StellaOps.Web/src/app/features/runs/components/first-signal-card/first-signal-card.component.scss` | | T6 | Create FirstSignalCard styles | — | DONE | `src/Web/StellaOps.Web/src/app/features/runs/components/first-signal-card/first-signal-card.component.scss` |
| T7 | Implement SSE integration | — | DONE | Uses run stream SSE (`first_signal`) via `EventSourceFactory`; requires `tenant` query fallback in Orchestrator stream endpoints. | | T7 | Implement SSE integration | — | DONE | Uses run stream SSE (`first_signal`) via `EventSourceFactory`; requires `tenant` query fallback in Orchestrator stream endpoints. |
| T8 | Implement polling fallback | — | DONE | `FirstSignalStore` starts polling (default 5s) when SSE errors. | | T8 | Implement polling fallback | — | DONE | `FirstSignalStore` starts polling (default 5s) when SSE errors. |
| T9 | Implement TTFS telemetry | — | BLOCKED | Telemetry client/contract for `ttfs_start` + `ttfs_signal_rendered` not present in Web; requires platform decision. | | T9 | Implement TTFS telemetry | — | DOING | Implement Web telemetry client + TTFS event emission (`ttfs_start`, `ttfs_signal_rendered`) with sampling and offline-safe buffering. |
| T10 | Create prefetch service | — | DONE | `src/Web/StellaOps.Web/src/app/features/runs/services/first-signal-prefetch.service.ts` | | T10 | Create prefetch service | — | DONE | `src/Web/StellaOps.Web/src/app/features/runs/services/first-signal-prefetch.service.ts` |
| T11 | Integrate into run detail page | — | DONE | Integrated into `src/Web/StellaOps.Web/src/app/features/console/console-status.component.html` as interim run-surface. | | T11 | Integrate into run detail page | — | DONE | Integrated into `src/Web/StellaOps.Web/src/app/features/console/console-status.component.html` as interim run-surface. |
| T12 | Create Storybook stories | — | DONE | `src/Web/StellaOps.Web/src/stories/runs/first-signal-card.stories.ts` | | T12 | Create Storybook stories | — | DONE | `src/Web/StellaOps.Web/src/stories/runs/first-signal-card.stories.ts` |
| T13 | Create unit tests | — | DONE | `src/Web/StellaOps.Web/src/app/core/api/first-signal.store.spec.ts` | | T13 | Create unit tests | — | DONE | `src/Web/StellaOps.Web/src/app/core/api/first-signal.store.spec.ts` |
| T14 | Create e2e tests | — | DONE | `src/Web/StellaOps.Web/tests/e2e/first-signal-card.spec.ts` | | T14 | Create e2e tests | — | DONE | `src/Web/StellaOps.Web/tests/e2e/first-signal-card.spec.ts` |
| T15 | Create accessibility tests | — | DONE | `src/Web/StellaOps.Web/tests/e2e/a11y-smoke.spec.ts` includes `/console/status`. | | T15 | Create accessibility tests | — | DONE | `src/Web/StellaOps.Web/tests/e2e/a11y-smoke.spec.ts` includes `/console/status`. |
| T16 | Configure telemetry sampling | — | BLOCKED | No Web telemetry config wiring yet (`AppConfig.telemetry.sampleRate` unused). | | T16 | Configure telemetry sampling | — | DOING | Wire `AppConfig.telemetry.sampleRate` into telemetry client sampling decisions and expose defaults in config. |
| T17 | Add i18n keys for micro-copy | — | BLOCKED | i18n framework not configured in `src/Web/StellaOps.Web` (no `@ngx-translate/*` / Angular i18n usage). | | T17 | Add i18n keys for micro-copy | — | DOING | Add i18n framework and migrate FirstSignalCard micro-copy to translation keys (EN baseline). |
--- ---
@@ -1781,3 +1781,4 @@ npx ngx-translate-extract \
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-12-15 | Implemented FirstSignalCard + store/client, quickstart mock, Storybook story, unit/e2e/a11y coverage; added Orchestrator stream tenant query fallback; marked telemetry/i18n tasks BLOCKED pending platform decisions. | Agent | | 2025-12-15 | Implemented FirstSignalCard + store/client, quickstart mock, Storybook story, unit/e2e/a11y coverage; added Orchestrator stream tenant query fallback; marked telemetry/i18n tasks BLOCKED pending platform decisions. | Agent |
| 2025-12-17 | Unblocked T9/T16/T17 by selecting a Web telemetry+sampling contract and adding an i18n framework; started implementation and test updates. | Agent |

View File

@@ -52,13 +52,13 @@ scanner:
| T4 | Create `TrustAnchorRegistry` service | DONE | Agent | Resolution by PURL | | T4 | Create `TrustAnchorRegistry` service | DONE | Agent | Resolution by PURL |
| T5 | Add configuration binding in `Program.cs` | DONE | Agent | | | T5 | Add configuration binding in `Program.cs` | DONE | Agent | |
| T6 | Create `OfflineKitOptionsValidator` | DONE | Agent | Startup validation | | T6 | Create `OfflineKitOptionsValidator` | DONE | Agent | Startup validation |
| T7 | Integrate with `DsseVerifier` | BLOCKED | Agent | No Scanner-side offline import service consumes DSSE verification yet. | | T7 | Integrate with `DsseVerifier` | DOING | Agent | Implement Scanner OfflineKit import host and consume DSSE verification with trust anchor resolution. |
| T8 | Implement DSSE failure handling per §7.2 | BLOCKED | Agent | Requires OfflineKit import pipeline/endpoints to exist. | | T8 | Implement DSSE failure handling per §7.2 | DOING | Agent | Implement ProblemDetails + log/metric reason codes; respect `requireDsse` soft-fail mode. |
| T9 | Add `rekorOfflineMode` enforcement | BLOCKED | Agent | Requires an offline Rekor snapshot verifier (not present in current codebase). | | T9 | Add `rekorOfflineMode` enforcement | DOING | Agent | Implement offline Rekor receipt verification and enforce no-network posture when enabled. |
| T10 | Create configuration schema documentation | DONE | Agent | Added `src/Scanner/docs/schemas/scanner-offline-kit-config.schema.json`. | | T10 | Create configuration schema documentation | DONE | Agent | Added `src/Scanner/docs/schemas/scanner-offline-kit-config.schema.json`. |
| T11 | Write unit tests for PURL matcher | DONE | Agent | Added coverage in `src/Scanner/__Tests/StellaOps.Scanner.Core.Tests`. | | T11 | Write unit tests for PURL matcher | DONE | Agent | Added coverage in `src/Scanner/__Tests/StellaOps.Scanner.Core.Tests`. |
| T12 | Write unit tests for trust anchor resolution | DONE | Agent | Added coverage for registry + validator in `src/Scanner/__Tests/StellaOps.Scanner.Core.Tests`. | | T12 | Write unit tests for trust anchor resolution | DONE | Agent | Added coverage for registry + validator in `src/Scanner/__Tests/StellaOps.Scanner.Core.Tests`. |
| T13 | Write integration tests for offline import | BLOCKED | Agent | Requires OfflineKit import pipeline/endpoints to exist. | | T13 | Write integration tests for offline import | DOING | Agent | Add Scanner.WebService OfflineKit import endpoint tests (success + failure + soft-fail) with deterministic fixtures. |
| T14 | Update Helm chart values | DONE | Agent | Added OfflineKit env vars to `deploy/helm/stellaops/values-*.yaml`. | | T14 | Update Helm chart values | DONE | Agent | Added OfflineKit env vars to `deploy/helm/stellaops/values-*.yaml`. |
| T15 | Update docker-compose samples | DONE | Agent | Added OfflineKit env vars to `deploy/compose/docker-compose.*.yaml`. | | T15 | Update docker-compose samples | DONE | Agent | Added OfflineKit env vars to `deploy/compose/docker-compose.*.yaml`. |
@@ -708,6 +708,7 @@ scanner:
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-12-15 | Implemented OfflineKit options/validator + trust anchor matcher/registry; wired Scanner.WebService options binding + DI; marked T7-T9 blocked pending import pipeline + offline Rekor verifier. | Agent | | 2025-12-15 | Implemented OfflineKit options/validator + trust anchor matcher/registry; wired Scanner.WebService options binding + DI; marked T7-T9 blocked pending import pipeline + offline Rekor verifier. | Agent |
| 2025-12-17 | Unblocked T7-T9/T13 by implementing a Scanner-side OfflineKit import host (API + services) and offline Rekor receipt verification; started wiring DSSE/Rekor failure handling and integration tests. | Agent |
## Decisions & Risks ## Decisions & Risks
- `T7/T8` blocked: Scanner has no OfflineKit import pipeline consuming DSSE verification yet (owning module + API/service design needed). - `T7/T8` blocked: Scanner has no OfflineKit import pipeline consuming DSSE verification yet (owning module + API/service design needed).

View File

@@ -42,7 +42,7 @@
| T4 | Implement `attestor_rekor_success_total` counter | DONE | Agent | Implement in `OfflineKitMetrics` (call sites may land later). | | T4 | Implement `attestor_rekor_success_total` counter | DONE | Agent | Implement in `OfflineKitMetrics` (call sites may land later). |
| T5 | Implement `attestor_rekor_retry_total` counter | DONE | Agent | Implement in `OfflineKitMetrics` (call sites may land later). | | T5 | Implement `attestor_rekor_retry_total` counter | DONE | Agent | Implement in `OfflineKitMetrics` (call sites may land later). |
| T6 | Implement `rekor_inclusion_latency` histogram | DONE | Agent | Implement in `OfflineKitMetrics` (call sites may land later). | | T6 | Implement `rekor_inclusion_latency` histogram | DONE | Agent | Implement in `OfflineKitMetrics` (call sites may land later). |
| T7 | Register metrics with Prometheus endpoint | BLOCKED | Agent | No backend Offline Kit import service/endpoint yet (`/api/offline-kit/import` not implemented in `src/**`); decide host/exporter surface for `/metrics`. | | T7 | Register metrics with Prometheus endpoint | DOING | Agent | Implement Scanner OfflineKit import host and expose `/metrics` with Offline Kit counters/histograms (Prometheus text format). |
| **Logging (G12)** | | | | | | **Logging (G12)** | | | | |
| T8 | Define structured logging constants | DONE | Agent | Add `OfflineKitLogFields` + scope helpers. | | T8 | Define structured logging constants | DONE | Agent | Add `OfflineKitLogFields` + scope helpers. |
| T9 | Update `ImportValidator` logging | DONE | Agent | Align log templates + tenant scope usage. | | T9 | Update `ImportValidator` logging | DONE | Agent | Align log templates + tenant scope usage. |
@@ -58,7 +58,7 @@
| T17 | Create migration for `offline_kit_audit` table | DONE | Agent | Add `authority.offline_kit_audit` + indexes + RLS policy. | | T17 | Create migration for `offline_kit_audit` table | DONE | Agent | Add `authority.offline_kit_audit` + indexes + RLS policy. |
| T18 | Implement `IOfflineKitAuditRepository` | DONE | Agent | Repository + query helpers (tenant/type/result). | | T18 | Implement `IOfflineKitAuditRepository` | DONE | Agent | Repository + query helpers (tenant/type/result). |
| T19 | Create audit event emitter service | DONE | Agent | Emitter wraps repository and must not fail import flows. | | T19 | Create audit event emitter service | DONE | Agent | Emitter wraps repository and must not fail import flows. |
| T20 | Wire audit to import/activation flows | BLOCKED | Agent | No backend Offline Kit import host/activation flow in `src/**` yet; wire once `POST /api/offline-kit/import` exists. | | T20 | Wire audit to import/activation flows | DOING | Agent | Wire `IOfflineKitAuditEmitter` into Scanner OfflineKit import/activation flow and validate tenant-scoped rows. |
| **Testing & Docs** | | | | | | **Testing & Docs** | | | | |
| T21 | Write unit tests for metrics | DONE | Agent | Cover instrument names + label sets via `MeterListener`. | | T21 | Write unit tests for metrics | DONE | Agent | Cover instrument names + label sets via `MeterListener`. |
| T22 | Write integration tests for audit | DONE | Agent | Cover migration + insert/query via Authority Postgres Testcontainers fixture (requires Docker). | | T22 | Write integration tests for audit | DONE | Agent | Cover migration + insert/query via Authority Postgres Testcontainers fixture (requires Docker). |
@@ -806,6 +806,7 @@ public sealed class OfflineKitAuditEmitter : IOfflineKitAuditEmitter
| 2025-12-15 | Added Authority Postgres migration + repository/emitter for `authority.offline_kit_audit`; marked `T20` `BLOCKED` pending an owning backend import/activation flow. | Agent | | 2025-12-15 | Added Authority Postgres migration + repository/emitter for `authority.offline_kit_audit`; marked `T20` `BLOCKED` pending an owning backend import/activation flow. | Agent |
| 2025-12-15 | Completed `T1`-`T6`, `T8`-`T19`, `T21`-`T24` (metrics/logging/codes/audit, tests, docs, dashboard); left `T7`/`T20` `BLOCKED` pending an owning Offline Kit import host. | Agent | | 2025-12-15 | Completed `T1`-`T6`, `T8`-`T19`, `T21`-`T24` (metrics/logging/codes/audit, tests, docs, dashboard); left `T7`/`T20` `BLOCKED` pending an owning Offline Kit import host. | Agent |
| 2025-12-15 | Cross-cutting Postgres RLS compatibility: set both `app.tenant_id` and `app.current_tenant` on tenant-scoped connections (shared `StellaOps.Infrastructure.Postgres`). | Agent | | 2025-12-15 | Cross-cutting Postgres RLS compatibility: set both `app.tenant_id` and `app.current_tenant` on tenant-scoped connections (shared `StellaOps.Infrastructure.Postgres`). | Agent |
| 2025-12-17 | Unblocked `T7`/`T20` by implementing a Scanner-owned Offline Kit import host; started wiring Prometheus `/metrics` surface and Authority audit emission into import/activation flow. | Agent |
## Decisions & Risks ## Decisions & Risks
- **Prometheus exporter choice (Importer):** `T7` is `BLOCKED` because the repo currently has no backend Offline Kit import host (no `src/**` implementation for `POST /api/offline-kit/import`), so there is no clear owning service to expose `/metrics`. - **Prometheus exporter choice (Importer):** `T7` is `BLOCKED` because the repo currently has no backend Offline Kit import host (no `src/**` implementation for `POST /api/offline-kit/import`), so there is no clear owning service to expose `/metrics`.

View File

@@ -3,7 +3,7 @@
**Epic:** Time-to-First-Signal (TTFS) Implementation **Epic:** Time-to-First-Signal (TTFS) Implementation
**Module:** Scheduler, Web UI **Module:** Scheduler, Web UI
**Working Directory:** `src/Scheduler/`, `src/Web/StellaOps.Web/` **Working Directory:** `src/Scheduler/`, `src/Web/StellaOps.Web/`
**Status:** TODO **Status:** DOING
**Created:** 2025-12-14 **Created:** 2025-12-14
**Target Completion:** TBD **Target Completion:** TBD
**Depends On:** SPRINT_0340_0001_0001 (FirstSignalCard UI) **Depends On:** SPRINT_0340_0001_0001 (FirstSignalCard UI)
@@ -39,7 +39,7 @@ This sprint delivers enhancements to the TTFS system including predictive failur
| T1 | Create `failure_signatures` table | Agent | DONE | Added to scheduler.sql | | T1 | Create `failure_signatures` table | Agent | DONE | Added to scheduler.sql |
| T2 | Create `IFailureSignatureRepository` | Agent | DONE | Interface + Postgres impl | | T2 | Create `IFailureSignatureRepository` | Agent | DONE | Interface + Postgres impl |
| T3 | Implement `FailureSignatureIndexer` | Agent | DONE | Background indexer service | | T3 | Implement `FailureSignatureIndexer` | Agent | DONE | Background indexer service |
| T4 | Integrate signatures into FirstSignal | — | BLOCKED | Requires cross-module integration design (Orchestrator -> Scheduler). Added GetBestMatchAsync to IFailureSignatureRepository. Need abstraction/client pattern. | | T4 | Integrate signatures into FirstSignal | — | DOING | Implement Scheduler WebService endpoint + Orchestrator client to surface best-match failure signature as `lastKnownOutcome` in FirstSignal response. |
| T5 | Add "Verify locally" commands to EvidencePanel | Agent | DONE | Copy affordances | | T5 | Add "Verify locally" commands to EvidencePanel | Agent | DONE | Copy affordances |
| T6 | Create ProofSpine sub-component | Agent | DONE | Bundle hashes | | T6 | Create ProofSpine sub-component | Agent | DONE | Bundle hashes |
| T7 | Create verification command templates | Agent | DONE | Cosign/Rekor | | T7 | Create verification command templates | Agent | DONE | Cosign/Rekor |
@@ -1903,6 +1903,7 @@ export async function setupPlaywrightDeterministic(page: Page): Promise<void> {
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
| --- | --- | --- | | --- | --- | --- |
| 2025-12-16 | T4: Added `GetBestMatchAsync` to `IFailureSignatureRepository` and implemented in Postgres repository. Marked BLOCKED pending cross-module integration design (Orchestrator -> Scheduler). | Agent | | 2025-12-16 | T4: Added `GetBestMatchAsync` to `IFailureSignatureRepository` and implemented in Postgres repository. Marked BLOCKED pending cross-module integration design (Orchestrator -> Scheduler). | Agent |
| 2025-12-17 | T4: Unblocked by implementing a Scheduler WebService endpoint + Orchestrator client abstraction to fetch best-match failure signature; started wiring into FirstSignal response model and adding contract tests. | Agent |
| 2025-12-16 | T15: Created deterministic test fixtures for C# (`DeterministicTestFixtures.cs`) and TypeScript (`deterministic-fixtures.ts`) with frozen timestamps, seeded RNG, and pre-generated UUIDs. | Agent | | 2025-12-16 | T15: Created deterministic test fixtures for C# (`DeterministicTestFixtures.cs`) and TypeScript (`deterministic-fixtures.ts`) with frozen timestamps, seeded RNG, and pre-generated UUIDs. | Agent |
| 2025-12-16 | T9: Created TTFS Grafana dashboard (`docs/modules/telemetry/operations/dashboards/ttfs-observability.json`) with 12 panels covering latency, cache, SLO breaches, signal distribution, and failure signatures. | Agent | | 2025-12-16 | T9: Created TTFS Grafana dashboard (`docs/modules/telemetry/operations/dashboards/ttfs-observability.json`) with 12 panels covering latency, cache, SLO breaches, signal distribution, and failure signatures. | Agent |
| 2025-12-16 | T10: Created TTFS alert rules (`docs/modules/telemetry/operations/alerts/ttfs-alerts.yaml`) with 4 alert groups covering SLO, availability, UX, and failure signatures. | Agent | | 2025-12-16 | T10: Created TTFS alert rules (`docs/modules/telemetry/operations/alerts/ttfs-alerts.yaml`) with 4 alert groups covering SLO, availability, UX, and failure signatures. | Agent |

View File

@@ -61,7 +61,7 @@ Per advisory §5:
| T5 | Implement SBOM collector (CycloneDX, SPDX) | DONE | Agent | `CycloneDxParser`, `SpdxParser`, `SbomParserFactory`, `SbomCollector` in Reconciliation/Parsers. | | T5 | Implement SBOM collector (CycloneDX, SPDX) | DONE | Agent | `CycloneDxParser`, `SpdxParser`, `SbomParserFactory`, `SbomCollector` in Reconciliation/Parsers. |
| T6 | Implement attestation collector | DONE | Agent | `IAttestationParser`, `DsseAttestationParser`, `AttestationCollector` in Reconciliation/Parsers. | | T6 | Implement attestation collector | DONE | Agent | `IAttestationParser`, `DsseAttestationParser`, `AttestationCollector` in Reconciliation/Parsers. |
| T7 | Integrate with `DsseVerifier` for validation | DONE | Agent | `AttestationCollector` integrates with `DsseVerifier` for DSSE signature verification. | | T7 | Integrate with `DsseVerifier` for validation | DONE | Agent | `AttestationCollector` integrates with `DsseVerifier` for DSSE signature verification. |
| T8 | Integrate with Rekor offline verifier | BLOCKED | Agent | Rekor offline verifier not found in AirGap module. Attestor module has online RekorBackend. Need offline Merkle proof verifier. | | T8 | Integrate with Rekor offline verifier | DOING | Agent | Implement offline Rekor receipt verifier (Merkle inclusion + checkpoint signature) and wire into AttestationCollector when `VerifyRekorProofs=true`. |
| **Step 3: Normalization** | | | | | | **Step 3: Normalization** | | | | |
| T9 | Design normalization rules | DONE | Agent | `NormalizationOptions` with configurable rules. | | T9 | Design normalization rules | DONE | Agent | `NormalizationOptions` with configurable rules. |
| T10 | Implement stable JSON sorting | DONE | Agent | `JsonNormalizer.NormalizeObject()` with ordinal key sorting. | | T10 | Implement stable JSON sorting | DONE | Agent | `JsonNormalizer.NormalizeObject()` with ordinal key sorting. |
@@ -77,10 +77,10 @@ Per advisory §5:
| T18 | Design `EvidenceGraph` schema | DONE | Agent | `EvidenceGraph`, `EvidenceNode`, `EvidenceEdge` models. | | T18 | Design `EvidenceGraph` schema | DONE | Agent | `EvidenceGraph`, `EvidenceNode`, `EvidenceEdge` models. |
| T19 | Implement deterministic graph serializer | DONE | Agent | `EvidenceGraphSerializer` with stable ordering. | | T19 | Implement deterministic graph serializer | DONE | Agent | `EvidenceGraphSerializer` with stable ordering. |
| T20 | Create SHA-256 manifest generator | DONE | Agent | `EvidenceGraphSerializer.ComputeHash()` writes `evidence-graph.sha256`. | | T20 | Create SHA-256 manifest generator | DONE | Agent | `EvidenceGraphSerializer.ComputeHash()` writes `evidence-graph.sha256`. |
| T21 | Integrate DSSE signing for output | BLOCKED | Agent | Signer module (`StellaOps.Signer`) is separate from AirGap. Need cross-module integration pattern or abstraction. | | T21 | Integrate DSSE signing for output | DOING | Agent | Implement local DSSE signing of `evidence-graph.json` using `StellaOps.Attestor.Envelope` + ECDSA PEM key option; keep output deterministic. |
| **Integration & Testing** | | | | | | **Integration & Testing** | | | | |
| T22 | Create `IEvidenceReconciler` service | DONE | Agent | `IEvidenceReconciler` + `EvidenceReconciler` implementing 5-step algorithm. | | T22 | Create `IEvidenceReconciler` service | DONE | Agent | `IEvidenceReconciler` + `EvidenceReconciler` implementing 5-step algorithm. |
| T23 | Wire to CLI `verify offline` command | BLOCKED | Agent | CLI module (`StellaOps.Cli`) is separate from AirGap. Sprint 0339 covers CLI offline commands. | | T23 | Wire to CLI `verify offline` command | DOING | Agent | CLI `verify offline` calls reconciler and returns deterministic pass/fail + violations; shared policy loader. |
| T24 | Write golden-file tests | DONE | Agent | `CycloneDxParserTests`, `SpdxParserTests`, `DsseAttestationParserTests` with fixtures. | | T24 | Write golden-file tests | DONE | Agent | `CycloneDxParserTests`, `SpdxParserTests`, `DsseAttestationParserTests` with fixtures. |
| T25 | Write property-based tests | DONE | Agent | `SourcePrecedenceLatticePropertyTests` verifying lattice algebraic properties. | | T25 | Write property-based tests | DONE | Agent | `SourcePrecedenceLatticePropertyTests` verifying lattice algebraic properties. |
| T26 | Update documentation | DONE | Agent | Created `docs/modules/airgap/evidence-reconciliation.md`. | | T26 | Update documentation | DONE | Agent | Created `docs/modules/airgap/evidence-reconciliation.md`. |
@@ -984,6 +984,7 @@ public sealed record ReconciliationResult(
| 2025-12-16 | Implemented property-based tests for lattice algebraic properties (`T25`): commutativity, associativity, idempotence, absorption laws, and merge determinism. | Agent | | 2025-12-16 | Implemented property-based tests for lattice algebraic properties (`T25`): commutativity, associativity, idempotence, absorption laws, and merge determinism. | Agent |
| 2025-12-16 | Created evidence reconciliation documentation (`T26`) in `docs/modules/airgap/evidence-reconciliation.md`. | Agent | | 2025-12-16 | Created evidence reconciliation documentation (`T26`) in `docs/modules/airgap/evidence-reconciliation.md`. | Agent |
| 2025-12-16 | Integrated DsseVerifier into AttestationCollector (`T7`). Marked T8, T21, T23 as BLOCKED pending cross-module integration patterns. | Agent | | 2025-12-16 | Integrated DsseVerifier into AttestationCollector (`T7`). Marked T8, T21, T23 as BLOCKED pending cross-module integration patterns. | Agent |
| 2025-12-17 | Unblocked T8/T21/T23 by implementing an offline Rekor receipt verifier contract + local DSSE signing path, and wiring reconciliation into CLI `verify offline`. | Agent |
## Decisions & Risks ## Decisions & Risks
- **Rekor offline verifier dependency:** `T8` depends on an offline Rekor inclusion proof verifier contract/library (see `docs/implplan/SPRINT_3000_0001_0001_rekor_merkle_proof_verification.md`). - **Rekor offline verifier dependency:** `T8` depends on an offline Rekor inclusion proof verifier contract/library (see `docs/implplan/SPRINT_3000_0001_0001_rekor_merkle_proof_verification.md`).

View File

@@ -4,7 +4,7 @@
**Feature:** Centralized rate limiting for Stella Router as standalone product **Feature:** Centralized rate limiting for Stella Router as standalone product
**Advisory Source:** `docs/product-advisories/unprocessed/15-Dec-2025 - Designing 202 + RetryAfter Backpressure Control.md` **Advisory Source:** `docs/product-advisories/unprocessed/15-Dec-2025 - Designing 202 + RetryAfter Backpressure Control.md`
**Owner:** Router Team **Owner:** Router Team
**Status:** PLANNING → READY FOR IMPLEMENTATION **Status:** DOING (Sprints 13 DONE; Sprint 4 DONE (N/A); Sprint 5 DOING; Sprint 6 TODO)
**Priority:** HIGH - Core feature for Router product **Priority:** HIGH - Core feature for Router product
**Target Completion:** 6 weeks (4 weeks implementation + 2 weeks rollout) **Target Completion:** 6 weeks (4 weeks implementation + 2 weeks rollout)
@@ -61,10 +61,10 @@ Each target can have multiple rules (AND logic):
| Sprint | IMPLID | Duration | Focus | Status | | Sprint | IMPLID | Duration | Focus | Status |
|--------|--------|----------|-------|--------| |--------|--------|----------|-------|--------|
| **Sprint 1** | 1200_001_001 | 5-7 days | Core router rate limiting | DONE | | **Sprint 1** | 1200_001_001 | 5-7 days | Core router rate limiting | DONE |
| **Sprint 2** | 1200_001_002 | 2-3 days | Per-route granularity | TODO | | **Sprint 2** | 1200_001_002 | 2-3 days | Per-route granularity | DONE |
| **Sprint 3** | 1200_001_003 | 2-3 days | Rule stacking (multiple windows) | TODO | | **Sprint 3** | 1200_001_003 | 2-3 days | Rule stacking (multiple windows) | DONE |
| **Sprint 4** | 1200_001_004 | 3-4 days | Service migration (AdaptiveRateLimiter) | TODO | | **Sprint 4** | 1200_001_004 | 3-4 days | Service migration (AdaptiveRateLimiter) | DONE (N/A) |
| **Sprint 5** | 1200_001_005 | 3-5 days | Comprehensive testing | TODO | | **Sprint 5** | 1200_001_005 | 3-5 days | Comprehensive testing | DOING |
| **Sprint 6** | 1200_001_006 | 2 days | Documentation & rollout prep | TODO | | **Sprint 6** | 1200_001_006 | 2 days | Documentation & rollout prep | TODO |
**Total Implementation:** 17-24 days **Total Implementation:** 17-24 days
@@ -161,41 +161,38 @@ Each target can have multiple rules (AND logic):
## Delivery Tracker ## Delivery Tracker
### Sprint 1: Core Router Rate Limiting ### Sprint 1: Core Router Rate Limiting
- [ ] TODO: Rate limit abstractions - [x] Rate limit abstractions
- [ ] TODO: Valkey backend implementation - [x] Valkey backend implementation (Lua, fixed-window)
- [ ] TODO: Middleware integration - [x] Middleware integration (router pipeline)
- [ ] TODO: Metrics and observability - [x] Metrics and observability
- [ ] TODO: Configuration schema - [x] Configuration schema (rules + legacy compatibility)
### Sprint 2: Per-Route Granularity ### Sprint 2: Per-Route Granularity
- [ ] TODO: Route pattern matching - [x] Route pattern matching (exact/prefix/regex, specificity rules)
- [ ] TODO: Configuration extension - [x] Configuration extension (`routes` under microservices)
- [ ] TODO: Inheritance resolution - [x] Inheritance resolution (environment → microservice → route)
- [ ] TODO: Route-level testing - [x] Route-level testing (unit tests)
### Sprint 3: Rule Stacking ### Sprint 3: Rule Stacking
- [ ] TODO: Multi-rule configuration - [x] Multi-rule configuration (`rules[]` with legacy compatibility)
- [ ] TODO: AND logic evaluation - [x] AND logic evaluation (instance + environment)
- [ ] TODO: Lua script enhancement - [x] Lua script enhancement (multi-rule evaluation)
- [ ] TODO: Retry-After calculation - [x] Retry-After calculation (most restrictive)
### Sprint 4: Service Migration ### Sprint 4: Service Migration
- [ ] TODO: Extract Orchestrator configs - [x] Closed as N/A (no Orchestrator ingress wiring found); see `docs/implplan/SPRINT_1200_001_004_router_rate_limiting_service_migration.md`
- [ ] TODO: Add to Router config
- [ ] TODO: Refactor AdaptiveRateLimiter
- [ ] TODO: Integration validation
### Sprint 5: Comprehensive Testing ### Sprint 5: Comprehensive Testing
- [ ] TODO: Unit test suite - [x] Unit test suite (core + routes + rules)
- [ ] TODO: Integration test suite - [ ] Integration test suite (Valkey/Testcontainers) — see `docs/implplan/SPRINT_1200_001_005_router_rate_limiting_tests.md`
- [ ] TODO: Load tests (k6) - [ ] Load tests (k6) — see `docs/implplan/SPRINT_1200_001_005_router_rate_limiting_tests.md`
- [ ] TODO: Configuration matrix tests - [ ] Configuration matrix tests — see `docs/implplan/SPRINT_1200_001_005_router_rate_limiting_tests.md`
### Sprint 6: Documentation ### Sprint 6: Documentation
- [ ] TODO: Architecture docs - [ ] Architecture docs — see `docs/implplan/SPRINT_1200_001_006_router_rate_limiting_docs.md`
- [ ] TODO: Configuration guide - [ ] Configuration guide — see `docs/implplan/SPRINT_1200_001_006_router_rate_limiting_docs.md`
- [ ] TODO: Operational runbook - [ ] Operational runbook — see `docs/implplan/SPRINT_1200_001_006_router_rate_limiting_docs.md`
- [ ] TODO: Migration guide - [ ] Migration guide — see `docs/implplan/SPRINT_1200_001_006_router_rate_limiting_docs.md`
--- ---
@@ -214,9 +211,11 @@ Each target can have multiple rules (AND logic):
## Related Documentation ## Related Documentation
- **Advisory:** `docs/product-advisories/unprocessed/15-Dec-2025 - Designing 202 + RetryAfter Backpressure Control.md` - **Advisory:** `docs/product-advisories/unprocessed/15-Dec-2025 - Designing 202 + RetryAfter Backpressure Control.md`
- **Plan:** `C:\Users\VladimirMoushkov\.claude\plans\vectorized-kindling-rocket.md` - **Implementation:** `src/__Libraries/StellaOps.Router.Gateway/RateLimit/`
- **Tests:** `tests/StellaOps.Router.Gateway.Tests/`
- **Implementation Guides:** `docs/implplan/SPRINT_1200_001_00X_*.md` (see below) - **Implementation Guides:** `docs/implplan/SPRINT_1200_001_00X_*.md` (see below)
- **Architecture:** `docs/modules/router/rate-limiting.md` (to be created) - **Sprints:** `docs/implplan/SPRINT_1200_001_004_router_rate_limiting_service_migration.md`, `docs/implplan/SPRINT_1200_001_005_router_rate_limiting_tests.md`, `docs/implplan/SPRINT_1200_001_006_router_rate_limiting_docs.md`
- **Docs:** `docs/router/rate-limiting-routes.md`
--- ---
@@ -233,19 +232,12 @@ Each target can have multiple rules (AND logic):
| Date | Status | Notes | | Date | Status | Notes |
|------|--------|-------| |------|--------|-------|
| 2025-12-17 | PLANNING | Sprint plan created from advisory analysis | | 2025-12-17 | DOING | Sprints 13 DONE; Sprint 4 closed N/A; Sprint 5 tests started; Sprint 6 docs pending. |
| TBD | READY | All sprint files and docs created, ready for implementation |
| TBD | IN_PROGRESS | Sprint 1 started |
--- ---
## Next Steps ## Next Steps
1. ✅ Create master sprint tracker (this file) 1. Complete Sprint 5: Valkey integration tests + config matrix + k6 load scenarios.
2. ⏳ Create individual sprint files with detailed tasks 2. Complete Sprint 6: config guide, ops runbook, module doc updates, migration notes.
3. ⏳ Create implementation guide with technical details 3. Mark this master tracker DONE after Sprint 5/6 close.
4. ⏳ Create configuration reference
5. ⏳ Create testing strategy document
6. ⏳ Review with Architecture Guild
7. ⏳ Assign to implementation agent
8. ⏳ Begin Sprint 1

View File

@@ -4,7 +4,9 @@
**Sprint Duration:** 5-7 days **Sprint Duration:** 5-7 days
**Priority:** HIGH **Priority:** HIGH
**Dependencies:** None **Dependencies:** None
**Blocks:** Sprint 2, 3, 4, 5, 6 **Status:** DONE
**Blocks:** Sprint 4, 5, 6
**Evidence:** `src/__Libraries/StellaOps.Router.Gateway/RateLimit/`, `tests/StellaOps.Router.Gateway.Tests/`
--- ---
@@ -1137,15 +1139,23 @@ rate_limiting:
## Acceptance Criteria ## Acceptance Criteria
- [ ] Configuration loads from YAML correctly - [x] Configuration loads from YAML correctly
- [ ] Instance limiter enforces limits (in-memory, fast) - [x] Instance limiter enforces limits (in-memory, fast)
- [ ] Environment limiter enforces limits (Valkey-backed) - [x] Environment limiter enforces limits (Valkey-backed)
- [ ] 429 + Retry-After response format correct - [x] 429 + Retry-After response format correct
- [ ] Circuit breaker handles Valkey failures (fail-open) - [x] Circuit breaker handles Valkey failures (fail-open)
- [ ] Activation gate skips Valkey under low traffic - [x] Activation gate skips Valkey under low traffic
- [ ] Metrics exported to OpenTelemetry - [x] Metrics exported to OpenTelemetry
- [ ] All unit tests pass (>90% coverage) - [x] All unit tests pass
- [ ] Integration tests pass (TestServer + Testcontainers) - [x] Integration tests pass (middleware response + Valkey/Testcontainers) (Sprint 5)
---
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-17 | Marked sprint DONE; implemented Valkey-backed multi-rule limiter, fixed instance sliding window counter, updated middleware order, and added unit tests. | Automation |
--- ---

View File

@@ -4,7 +4,9 @@
**Sprint Duration:** 2-3 days **Sprint Duration:** 2-3 days
**Priority:** HIGH **Priority:** HIGH
**Dependencies:** Sprint 1 (Core implementation) **Dependencies:** Sprint 1 (Core implementation)
**Blocks:** Sprint 5 (Testing needs routes) **Status:** DONE
**Blocks:** Sprint 5 (additional integration/load testing)
**Evidence:** `src/__Libraries/StellaOps.Router.Gateway/RateLimit/`, `docs/router/rate-limiting-routes.md`, `tests/StellaOps.Router.Gateway.Tests/`
--- ---
@@ -652,14 +654,22 @@ policy:
## Acceptance Criteria ## Acceptance Criteria
- [ ] Route configuration models created - [x] Route configuration models created
- [ ] Route matching works (exact, prefix, regex) - [x] Route matching works (exact, prefix, regex)
- [ ] Specificity resolution correct - [x] Specificity resolution correct
- [ ] Inheritance works (global → microservice → route) - [x] Inheritance works (global → microservice → route)
- [ ] Integration with RateLimitService complete - [x] Integration with RateLimitService complete
- [ ] Unit tests pass (>90% coverage) - [x] Unit tests pass
- [ ] Integration tests pass - [x] Integration tests pass (covered in Sprint 5)
- [ ] Documentation complete - [x] Documentation complete
---
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-17 | Marked sprint DONE; implemented route config + matching + inheritance resolution; integrated into RateLimitService; added unit tests and docs. | Automation |
--- ---

View File

@@ -4,7 +4,9 @@
**Sprint Duration:** 2-3 days **Sprint Duration:** 2-3 days
**Priority:** HIGH **Priority:** HIGH
**Dependencies:** Sprint 1 (Core), Sprint 2 (Routes) **Dependencies:** Sprint 1 (Core), Sprint 2 (Routes)
**Blocks:** Sprint 5 (Testing) **Status:** DONE
**Blocks:** Sprint 5 (additional integration/load testing)
**Evidence:** `src/__Libraries/StellaOps.Router.Gateway/RateLimit/`, `tests/StellaOps.Router.Gateway.Tests/`
--- ---
@@ -463,14 +465,22 @@ public List<RateLimitRule> ResolveRulesForRoute(string microservice, string? rou
## Acceptance Criteria ## Acceptance Criteria
- [ ] Configuration supports rule arrays - [x] Configuration supports rule arrays
- [ ] Backward compatible with legacy single-window config - [x] Backward compatible with legacy single-window config
- [ ] Instance limiter evaluates all rules (AND logic) - [x] Instance limiter evaluates all rules (AND logic)
- [ ] Valkey Lua script handles multiple windows - [x] Valkey Lua script handles multiple windows
- [ ] Most restrictive Retry-After returned - [x] Most restrictive Retry-After returned
- [ ] Inheritance resolver merges rules correctly - [x] Inheritance resolver merges rules correctly
- [ ] Unit tests pass - [x] Unit tests pass
- [ ] Integration tests pass (Testcontainers) - [x] Integration tests pass (Valkey/Testcontainers) (Sprint 5)
---
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-17 | Marked sprint DONE; implemented rule arrays and multi-window evaluation for instance + environment (Valkey Lua); added unit tests. | Automation |
--- ---

View File

@@ -0,0 +1,36 @@
# Sprint 1200_001_004 · Router Rate Limiting · Service Migration (AdaptiveRateLimiter)
## Topic & Scope
- Close the planned migration of `AdaptiveRateLimiter` (Orchestrator) into Router rate limiting.
- Confirm whether any production HTTP paths still enforce service-level rate limiting and therefore require migration.
- **Working directory:** `src/Orchestrator/StellaOps.Orchestrator`.
- **Evidence:** `src/__Libraries/StellaOps.Router.Gateway/RateLimit/` (router limiter exists) and Orchestrator code search indicates `AdaptiveRateLimiter` is not wired into HTTP ingress (library-only).
## Dependencies & Concurrency
- Depends on: `SPRINT_1200_001_001`, `SPRINT_1200_001_002`, `SPRINT_1200_001_003` (rate limiting landed in Router).
- Safe to execute in parallel with Sprint 5/6 since no code changes are required for this closure.
## Documentation Prerequisites
- `docs/implplan/SPRINT_1200_001_000_router_rate_limiting_master.md`
- `docs/modules/router/architecture.md`
- `docs/modules/orchestrator/architecture.md`
## Delivery Tracker
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 1 | RRL-04-001 | DONE | N/A | Router · Orchestrator | Inventory usage of `AdaptiveRateLimiter` and any service-level HTTP rate limiting in Orchestrator ingress. |
| 2 | RRL-04-002 | DONE | N/A | Router · Architecture | Decide migration outcome: migrate, defer, or close as N/A based on inventory. |
| 3 | RRL-04-003 | DONE | Update master tracker | Router | Update `SPRINT_1200_001_000_router_rate_limiting_master.md` to reflect closure outcome. |
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-17 | Sprint created and closed as N/A: `AdaptiveRateLimiter` appears to be a library-only component in Orchestrator (tests + core) and is not wired into HTTP ingress; no service-level HTTP rate limiting was found to migrate. | Automation |
## Decisions & Risks
- **Decision:** Close Sprint 4 as N/A (no production wiring found). If Orchestrator (or any service) introduces HTTP-level rate limiting, open a dedicated migration sprint under that services working directory.
- **Risk:** Double-limiting during future migration if both service-level and router-level limiters are enabled. Mitigation: migration guide + staged rollout (shadow mode), and remove service-level limiters after router limits verified.
## Next Checkpoints
- None (closure sprint).

View File

@@ -0,0 +1,38 @@
# Sprint 1200_001_005 · Router Rate Limiting · Comprehensive Testing
## Topic & Scope
- Add Valkey-backed integration tests for the Lua fixed-window implementation (real Valkey).
- Expand deterministic unit coverage via configuration matrix tests (inheritance + routes + rule stacking).
- Add k6 load test scenarios for rate limiting (enforcement, retry-after correctness, overhead).
- **Working directory:** `tests/`.
- **Evidence:** `tests/StellaOps.Router.Gateway.Tests/`, `tests/load/`.
## Dependencies & Concurrency
- Depends on: `SPRINT_1200_001_001`, `SPRINT_1200_001_002`, `SPRINT_1200_001_003` (feature implementation).
- Can run in parallel with Sprint 6 docs.
## Documentation Prerequisites
- `docs/implplan/SPRINT_1200_001_IMPLEMENTATION_GUIDE.md`
- `docs/router/rate-limiting-routes.md`
- `docs/modules/router/architecture.md`
## Delivery Tracker
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 1 | RRL-05-001 | DONE | Run with `STELLAOPS_INTEGRATION_TESTS=true` | QA · Router | Valkey integration tests validating multi-rule Lua behavior and Retry-After bounds. |
| 2 | RRL-05-002 | DONE | Covered by unit tests | QA · Router | Configuration matrix unit tests (inheritance replacement + route specificity + rule stacking). |
| 3 | RRL-05-003 | DONE | `tests/load/router-rate-limiting-load-test.js` | QA · Router | k6 load tests for rate limiting scenarios (AF) and doc updates in `tests/load/README.md`. |
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-17 | Sprint created; RRL-05-001 started. | Automation |
| 2025-12-17 | Completed RRL-05-001 and RRL-05-002: added Testcontainers-backed Valkey integration tests (opt-in via `STELLAOPS_INTEGRATION_TESTS=true`) and expanded unit coverage for inheritance + activation gate behavior. | Automation |
| 2025-12-17 | Completed RRL-05-003: added k6 suite `tests/load/router-rate-limiting-load-test.js` and documented usage in `tests/load/README.md`. | Automation |
## Decisions & Risks
- **Decision:** Integration tests require Docker; they are opt-in (skipped unless explicitly enabled) to keep `dotnet test StellaOps.Router.slnx` runnable without Docker.
- **Risk:** Flaky timing around fixed-window boundaries. Mitigation: assert ranges (not exact seconds) and use small windows with slack.
## Next Checkpoints
- None scheduled; complete tasks and mark sprint DONE.

View File

@@ -0,0 +1,41 @@
# Sprint 1200_001_006 · Router Rate Limiting · Documentation & Rollout Prep
## Topic & Scope
- Publish user-facing configuration guide and ops runbook for Router rate limiting.
- Update Router module docs to reflect the new centralized rate limiting feature and where it sits in the request pipeline.
- Add migration guidance to avoid double-limiting during rollout.
- **Working directory:** `docs/`.
- **Evidence:** `docs/router/`, `docs/operations/`, `docs/modules/router/`.
## Dependencies & Concurrency
- Depends on: `SPRINT_1200_001_001`, `SPRINT_1200_001_002`, `SPRINT_1200_001_003`.
- Can run in parallel with Sprint 5 tests.
## Documentation Prerequisites
- `docs/README.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/router/architecture.md`
- `docs/router/rate-limiting-routes.md`
## Delivery Tracker
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
| --- | --- | --- | --- | --- | --- |
| 1 | RRL-06-001 | DONE | Links added | Docs · Router | Architecture updates + links (Router module docs + high-level router docs). |
| 2 | RRL-06-002 | DONE | `docs/router/rate-limiting.md` | Docs · Router | User configuration guide: `docs/router/rate-limiting.md` (rules, inheritance, routes, examples). |
| 3 | RRL-06-003 | DONE | `docs/operations/router-rate-limiting.md` | Ops · Router | Operational runbook: `docs/operations/router-rate-limiting.md` (dashboards, alerts, rollout, failure modes). |
| 4 | RRL-06-004 | DONE | Migration notes published | Router · Docs | Migration guide section: avoid double-limiting, staged rollout, and decommission service-level limiters. |
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2025-12-17 | Sprint created; awaiting implementation. | Automation |
| 2025-12-17 | Started RRL-06-001. | Automation |
| 2025-12-17 | Completed RRL-06-001..004: added `docs/router/rate-limiting.md`, `docs/operations/router-rate-limiting.md`, `docs/modules/router/rate-limiting.md`; updated `docs/router/rate-limiting-routes.md`, `docs/modules/router/README.md`, and `docs/modules/router/architecture.md`. | Automation |
## Decisions & Risks
- **Decision:** Keep docs offline-friendly: no external CDNs/snippets; prefer deterministic, copy-pastable YAML fragments.
- **Risk:** Confusion during rollout if both router and service rate limiting are enabled. Mitigation: explicit migration guide + recommended rollout phases.
## Next Checkpoints
- None scheduled; complete tasks and mark sprint DONE.

View File

@@ -1,13 +1,15 @@
# Router Rate Limiting - Implementation Guide # Router Rate Limiting - Implementation Guide
**For:** Implementation agents executing Sprint 1200_001_001 through 1200_001_006 **For:** Implementation agents / reviewers for Sprint 1200_001_001 through 1200_001_006
**Status:** DOING (Sprints 13 DONE; Sprint 4 closed N/A; Sprints 56 in progress)
**Evidence:** `src/__Libraries/StellaOps.Router.Gateway/RateLimit/`, `tests/StellaOps.Router.Gateway.Tests/`
**Last Updated:** 2025-12-17 **Last Updated:** 2025-12-17
--- ---
## Purpose ## Purpose
This guide provides comprehensive technical context for implementing centralized rate limiting in Stella Router. It covers architecture decisions, patterns, gotchas, and operational considerations. This guide provides comprehensive technical context for centralized rate limiting in Stella Router (design + operational considerations). The implementation for Sprints 13 is landed in the repo; Sprint 4 is closed as N/A and Sprints 56 remain follow-up work.
--- ---

View File

@@ -1,14 +1,15 @@
# Router Rate Limiting - Sprint Package README # Router Rate Limiting - Sprint Package README
**Package Created:** 2025-12-17 **Package Created:** 2025-12-17
**For:** Implementation agents **For:** Implementation agents / reviewers
**Status:** DOING (Sprints 13 DONE; Sprint 4 DONE (N/A); Sprint 5 DOING; Sprint 6 TODO)
**Advisory Source:** `docs/product-advisories/unprocessed/15-Dec-2025 - Designing 202 + RetryAfter Backpressure Control.md` **Advisory Source:** `docs/product-advisories/unprocessed/15-Dec-2025 - Designing 202 + RetryAfter Backpressure Control.md`
--- ---
## Package Contents ## Package Contents
This sprint package contains everything needed to implement centralized rate limiting in Stella Router. This sprint package contains the original plan plus the landed implementation for centralized rate limiting in Stella Router.
### Core Sprint Files ### Core Sprint Files
@@ -18,15 +19,19 @@ This sprint package contains everything needed to implement centralized rate lim
| `SPRINT_1200_001_001_router_rate_limiting_core.md` | Sprint 1: Core implementation | Implementer - 5-7 days | | `SPRINT_1200_001_001_router_rate_limiting_core.md` | Sprint 1: Core implementation | Implementer - 5-7 days |
| `SPRINT_1200_001_002_router_rate_limiting_per_route.md` | Sprint 2: Per-route granularity | Implementer - 2-3 days | | `SPRINT_1200_001_002_router_rate_limiting_per_route.md` | Sprint 2: Per-route granularity | Implementer - 2-3 days |
| `SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md` | Sprint 3: Rule stacking | Implementer - 2-3 days | | `SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md` | Sprint 3: Rule stacking | Implementer - 2-3 days |
| `SPRINT_1200_001_004_router_rate_limiting_service_migration.md` | Sprint 4: Service migration (closed N/A) | Project manager / reviewer |
| `SPRINT_1200_001_005_router_rate_limiting_tests.md` | Sprint 5: Comprehensive testing | QA / implementer |
| `SPRINT_1200_001_006_router_rate_limiting_docs.md` | Sprint 6: Documentation & rollout prep | Docs / implementer |
| `SPRINT_1200_001_IMPLEMENTATION_GUIDE.md` | Technical reference | **READ FIRST** before coding | | `SPRINT_1200_001_IMPLEMENTATION_GUIDE.md` | Technical reference | **READ FIRST** before coding |
### Documentation Files (To Be Created in Sprint 6) ### Documentation Files
| File | Purpose | Created In | | File | Purpose | Created In |
|------|---------|------------| |------|---------|------------|
| `docs/router/rate-limiting-routes.md` | Per-route configuration guide | Sprint 2 |
| `docs/router/rate-limiting.md` | User-facing configuration guide | Sprint 6 | | `docs/router/rate-limiting.md` | User-facing configuration guide | Sprint 6 |
| `docs/operations/router-rate-limiting.md` | Operational runbook | Sprint 6 | | `docs/operations/router-rate-limiting.md` | Operational runbook | Sprint 6 |
| `docs/modules/router/architecture.md` | Architecture documentation | Sprint 6 | | `docs/modules/router/rate-limiting.md` | Module-level rate-limiting dossier | Sprint 6 |
--- ---
@@ -306,6 +311,38 @@ Copy this to master tracker and update as you progress:
## File Structure (After Implementation) ## File Structure (After Implementation)
### Actual (landed)
```
src/__Libraries/StellaOps.Router.Gateway/RateLimit/
CircuitBreaker.cs
EnvironmentRateLimiter.cs
InMemoryValkeyRateLimitStore.cs
InstanceRateLimiter.cs
LimitInheritanceResolver.cs
RateLimitConfig.cs
RateLimitDecision.cs
RateLimitMetrics.cs
RateLimitMiddleware.cs
RateLimitRule.cs
RateLimitRouteMatcher.cs
RateLimitService.cs
RateLimitServiceCollectionExtensions.cs
ValkeyRateLimitStore.cs
tests/StellaOps.Router.Gateway.Tests/
LimitInheritanceResolverTests.cs
InMemoryValkeyRateLimitStoreTests.cs
InstanceRateLimiterTests.cs
RateLimitConfigTests.cs
RateLimitRouteMatcherTests.cs
RateLimitServiceTests.cs
docs/router/rate-limiting-routes.md
```
### Original plan (reference)
``` ```
src/__Libraries/StellaOps.Router.Gateway/ src/__Libraries/StellaOps.Router.Gateway/
├── RateLimit/ ├── RateLimit/
@@ -351,8 +388,8 @@ __Tests/
│ ├── RouteMatchingTests.cs │ ├── RouteMatchingTests.cs
│ └── InheritanceResolverTests.cs │ └── InheritanceResolverTests.cs
tests/load/k6/ tests/load/
└── rate-limit-scenarios.js └── router-rate-limiting-load-test.js
``` ```
--- ---
@@ -443,7 +480,9 @@ rate_limiting:
- **Sprint 1:** `SPRINT_1200_001_001_router_rate_limiting_core.md` - **Sprint 1:** `SPRINT_1200_001_001_router_rate_limiting_core.md`
- **Sprint 2:** `SPRINT_1200_001_002_router_rate_limiting_per_route.md` - **Sprint 2:** `SPRINT_1200_001_002_router_rate_limiting_per_route.md`
- **Sprint 3:** `SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md` - **Sprint 3:** `SPRINT_1200_001_003_router_rate_limiting_rule_stacking.md`
- **Sprint 4-6:** To be created by implementer (templates in master tracker) - **Sprint 4:** `SPRINT_1200_001_004_router_rate_limiting_service_migration.md` (closed N/A)
- **Sprint 5:** `SPRINT_1200_001_005_router_rate_limiting_tests.md`
- **Sprint 6:** `SPRINT_1200_001_006_router_rate_limiting_docs.md`
### Technical Guides ### Technical Guides
- **Implementation Guide:** `SPRINT_1200_001_IMPLEMENTATION_GUIDE.md` (comprehensive) - **Implementation Guide:** `SPRINT_1200_001_IMPLEMENTATION_GUIDE.md` (comprehensive)
@@ -460,4 +499,4 @@ rate_limiting:
--- ---
**Ready to implement?** Start with the Implementation Guide, then proceed to Sprint 1! **Already implemented.** Review the master tracker and run `dotnet test StellaOps.Router.slnx -c Release`.

View File

@@ -37,13 +37,13 @@ Implement False-Negative Drift (FN-Drift) rate tracking for monitoring reclassif
| 4 | DRIFT-3404-004 | DONE | None | Scanner Team | Define `ClassificationChange` entity and `DriftCause` enum | | 4 | DRIFT-3404-004 | DONE | None | Scanner Team | Define `ClassificationChange` entity and `DriftCause` enum |
| 5 | DRIFT-3404-005 | DONE | After #1, #4 | Scanner Team | Implement `ClassificationHistoryRepository` | | 5 | DRIFT-3404-005 | DONE | After #1, #4 | Scanner Team | Implement `ClassificationHistoryRepository` |
| 6 | DRIFT-3404-006 | DONE | After #5 | Scanner Team | Implemented `ClassificationChangeTracker` service | | 6 | DRIFT-3404-006 | DONE | After #5 | Scanner Team | Implemented `ClassificationChangeTracker` service |
| 7 | DRIFT-3404-007 | BLOCKED | After #6 | Scanner Team | Requires scan completion pipeline integration point | | 7 | DRIFT-3404-007 | DONE | After #6 | Scanner Team | Integrated FN-drift tracking on report publish/scan completion pipeline |
| 8 | DRIFT-3404-008 | DONE | After #2 | Scanner Team | Implement `FnDriftCalculator` with stratification | | 8 | DRIFT-3404-008 | DONE | After #2 | Scanner Team | Implement `FnDriftCalculator` with stratification |
| 9 | DRIFT-3404-009 | DONE | After #8 | Telemetry Team | Implemented `FnDriftMetricsExporter` with Prometheus gauges | | 9 | DRIFT-3404-009 | DONE | After #8 | Telemetry Team | Implemented `FnDriftMetricsExporter` with Prometheus gauges |
| 10 | DRIFT-3404-010 | BLOCKED | After #9 | Telemetry Team | Requires SLO threshold configuration in telemetry stack | | 10 | DRIFT-3404-010 | DONE | After #9 | Telemetry Team | Added Prometheus alert rules for FN-drift thresholds |
| 11 | DRIFT-3404-011 | DONE | After #5 | Scanner Team | ClassificationChangeTrackerTests.cs added | | 11 | DRIFT-3404-011 | DONE | After #5 | Scanner Team | ClassificationChangeTrackerTests.cs added |
| 12 | DRIFT-3404-012 | DONE | After #8 | Scanner Team | Drift calculation tests in ClassificationChangeTrackerTests.cs | | 12 | DRIFT-3404-012 | DONE | After #8 | Scanner Team | Drift calculation tests in ClassificationChangeTrackerTests.cs |
| 13 | DRIFT-3404-013 | BLOCKED | After #7 | QA | Blocked by #7 pipeline integration | | 13 | DRIFT-3404-013 | DONE | After #7 | QA | Added webservice tests covering FN-drift tracking integration |
| 14 | DRIFT-3404-014 | DONE | After #2 | Docs Guild | Created `docs/metrics/fn-drift.md` | | 14 | DRIFT-3404-014 | DONE | After #2 | Docs Guild | Created `docs/metrics/fn-drift.md` |
## Wave Coordination ## Wave Coordination
@@ -526,6 +526,7 @@ public sealed class FnDriftMetrics
|------|------|----------|-----|-------| |------|------|----------|-----|-------|
| Materialized view refresh strategy | Decision | DB Team | Before #2 | Cron vs trigger | | Materialized view refresh strategy | Decision | DB Team | Before #2 | Cron vs trigger |
| High-volume insert optimization | Risk | Scanner Team | Before #7 | May need batch processing | | High-volume insert optimization | Risk | Scanner Team | Before #7 | May need batch processing |
| Verdict-to-classification mapping | Decision | Scanner Team | With #7 | Heuristic mapping from Policy verdict diffs to classification status (documented in code) |
--- ---
@@ -534,3 +535,8 @@ public sealed class FnDriftMetrics
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
|------------|--------|-------| |------------|--------|-------|
| 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer | | 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer |
| 2025-12-17 | Implemented scan completion integration, enabled drift view refresh+metrics export, added alert rules, and added QA tests. | Agent |
## Next Checkpoints
- None (sprint complete).

View File

@@ -585,3 +585,9 @@ public sealed record ReportedGate
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
|------------|--------|-------| |------------|--------|-------|
| 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer | | 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer |
## Next Checkpoints
- Integrate gate detection into RichGraph builder/writer (GATE-3405-009).
- Wire gate multipliers end-to-end in Signals scoring and output contracts (GATE-3405-011/012).
- Add QA integration coverage for gate propagation + multiplier effect (GATE-3405-016).

View File

@@ -1,17 +1,33 @@
# Sprint 3410: EPSS Ingestion & Storage # Sprint 3410.0001.0001 · EPSS Ingestion & Storage
## Metadata ## Topic & Scope
- Deliver deterministic EPSS v4 ingestion into Postgres (append-only history + current projection + change log).
- Support online and air-gap bundle sources with identical parsing and validation.
- Produce operator evidence (tests + runbook) proving determinism, idempotency, and partition safety.
**Sprint ID:** SPRINT_3410_0001_0001 **Sprint ID:** SPRINT_3410_0001_0001
**Implementation Plan:** IMPL_3410_epss_v4_integration_master_plan **Implementation Plan:** IMPL_3410_epss_v4_integration_master_plan
**Phase:** Phase 1 - MVP **Phase:** Phase 1 - MVP
**Priority:** P1 **Priority:** P1
**Estimated Effort:** 2 weeks **Estimated Effort:** 2 weeks
**Working Directory:** `src/Concelier/` **Working Directory:** `src/Scanner/`
**Dependencies:** None (foundational) **Dependencies:** None (foundational)
--- ---
## Dependencies & Concurrency
- **Depends on:** Scanner storage schema migration `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/008_epss_integration.sql`.
- **Blocking:** SPRINT_3410_0002_0001 (Scanner integration) depends on this sprint landing.
- **Safe to parallelize with:** Determinism scoring and reachability work (no schema overlap beyond Scanner).
## Documentation Prerequisites
- `docs/modules/scanner/epss-integration.md`
- `docs/product-advisories/archive/16-Dec-2025 - Merging EPSS v4 with CVSS v4 Frameworks.md`
- `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/008_epss_integration.sql`
## Overview ## Overview
Implement the **foundational EPSS v4 ingestion pipeline** for StellaOps. This sprint delivers daily automated import of EPSS (Exploit Prediction Scoring System) data from FIRST.org, storing it in a deterministic, append-only PostgreSQL schema with full provenance tracking. Implement the **foundational EPSS v4 ingestion pipeline** for StellaOps. This sprint delivers daily automated import of EPSS (Exploit Prediction Scoring System) data from FIRST.org, storing it in a deterministic, append-only PostgreSQL schema with full provenance tracking.
@@ -127,9 +143,7 @@ External Dependencies:
--- ---
## Task Breakdown ## Delivery Tracker
### Delivery Tracker
| ID | Task | Status | Owner | Est. | Notes | | ID | Task | Status | Owner | Est. | Notes |
|----|------|--------|-------|------|-------| |----|------|--------|-------|------|-------|
@@ -771,7 +785,9 @@ concelier:
--- ---
## Risks & Mitigations ## Decisions & Risks
- **Decision:** EPSS ingestion/storage is implemented against the Scanner schema for now; the original Concelier-first design text below is preserved for reference.
| Risk | Likelihood | Impact | Mitigation | | Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------| |------|------------|--------|------------|
@@ -838,5 +854,15 @@ concelier:
--- ---
## Execution Log
| Date (UTC) | Update | Owner |
|------------|--------|-------|
| 2025-12-17 | Normalized sprint file to standard template; aligned working directory to Scanner schema implementation; preserved original Concelier-first design text for reference. | Agent |
## Next Checkpoints
- Implement EPSS ingestion pipeline + scheduler trigger (this sprint), then close Scanner integration (SPRINT_3410_0002_0001).
**Sprint Status**: READY FOR IMPLEMENTATION **Sprint Status**: READY FOR IMPLEMENTATION
**Approval**: _____________________ Date: ___________ **Approval**: _____________________ Date: ___________

View File

@@ -6,6 +6,22 @@
**Working Directory:** `src/Unknowns/` **Working Directory:** `src/Unknowns/`
**Estimated Complexity:** Medium-High **Estimated Complexity:** Medium-High
## Topic & Scope
- Add a dedicated `unknowns` schema with bitemporal semantics for deterministic replay and compliance point-in-time queries.
- Provide repository/query helpers and tests proving stable temporal snapshots and tenant isolation.
- Deliver a Category C migration path from legacy VEX unknowns tables.
## Dependencies & Concurrency
- **Depends on:** PostgreSQL init scripts and base infrastructure migrations.
- **Safe to parallelize with:** All non-DB-cutover work (no runtime coupling).
## Documentation Prerequisites
- `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` (Section 3.4)
- `docs/db/SPECIFICATION.md`
--- ---
## 1. Objective ## 1. Objective
@@ -36,7 +52,7 @@ StellaOps scans produce "unknowns" - packages, versions, or ecosystems that cann
--- ---
## 3. Delivery Tracker ## Delivery Tracker
| # | Task | Status | Assignee | Notes | | # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------| |---|------|--------|----------|-------|
@@ -464,7 +480,7 @@ COMMIT;
--- ---
## 8. Decisions & Risks ## Decisions & Risks
| # | Decision/Risk | Status | Resolution | | # | Decision/Risk | Status | Resolution |
|---|---------------|--------|------------| |---|---------------|--------|------------|
@@ -493,3 +509,13 @@ COMMIT;
- Spec: `docs/db/SPECIFICATION.md` - Spec: `docs/db/SPECIFICATION.md`
- Rules: `docs/db/RULES.md` - Rules: `docs/db/RULES.md`
- Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` - Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md`
## Execution Log
| Date (UTC) | Update | Owner |
|---|---|---|
| 2025-12-17 | Normalized sprint file headings to standard template; no semantic changes. | Agent |
## Next Checkpoints
- None (sprint complete).

View File

@@ -6,6 +6,24 @@
**Working Directory:** `src/*/Migrations/` **Working Directory:** `src/*/Migrations/`
**Estimated Complexity:** Medium **Estimated Complexity:** Medium
## Topic & Scope
- Expand Row-Level Security (RLS) from `findings_ledger` to all tenant-scoped schemas for defense-in-depth.
- Standardize `*_app.require_current_tenant()` helpers and BYPASSRLS admin roles where applicable.
- Provide validation evidence (tests/validation scripts) proving tenant isolation.
## Dependencies & Concurrency
- **Depends on:** Existing Postgres schema baselines per module.
- **Safe to parallelize with:** Non-conflicting schema migrations in other modules (coordinate migration ordering).
## Documentation Prerequisites
- `docs/db/SPECIFICATION.md`
- `docs/db/RULES.md`
- `docs/db/VERIFICATION.md`
- `docs/modules/platform/architecture-overview.md`
--- ---
## 1. Objective ## 1. Objective
@@ -46,7 +64,7 @@ CREATE POLICY tenant_isolation ON table_name
--- ---
## 3. Delivery Tracker ## Delivery Tracker
| # | Task | Status | Assignee | Notes | | # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------| |---|------|--------|----------|-------|
@@ -566,7 +584,7 @@ $$;
--- ---
## 9. Decisions & Risks ## Decisions & Risks
| # | Decision/Risk | Status | Resolution | | # | Decision/Risk | Status | Resolution |
|---|---------------|--------|------------| |---|---------------|--------|------------|
@@ -577,7 +595,7 @@ $$;
--- ---
## 10. Definition of Done ## Definition of Done
- [x] All tenant-scoped tables have RLS enabled and forced - [x] All tenant-scoped tables have RLS enabled and forced
- [x] All tenant-scoped tables have tenant_isolation policy - [x] All tenant-scoped tables have tenant_isolation policy
@@ -595,3 +613,13 @@ $$;
- Reference implementation: `src/Findings/StellaOps.Findings.Ledger/migrations/007_enable_rls.sql` - Reference implementation: `src/Findings/StellaOps.Findings.Ledger/migrations/007_enable_rls.sql`
- PostgreSQL RLS docs: https://www.postgresql.org/docs/16/ddl-rowsecurity.html - PostgreSQL RLS docs: https://www.postgresql.org/docs/16/ddl-rowsecurity.html
- Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` (Section 2.2) - Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` (Section 2.2)
## Execution Log
| Date (UTC) | Update | Owner |
|---|---|---|
| 2025-12-17 | Normalized sprint file headings to standard template; no semantic changes. | Agent |
## Next Checkpoints
- None (sprint complete).

View File

@@ -6,6 +6,22 @@
**Working Directory:** `src/*/Migrations/` **Working Directory:** `src/*/Migrations/`
**Estimated Complexity:** High **Estimated Complexity:** High
## Topic & Scope
- Implement time-based RANGE partitioning for high-volume event/log tables to enable efficient retention and predictable performance.
- Standardize partition creation/retention automation via Scheduler partition maintenance.
- Provide validation evidence (scripts/tests) for partition health and pruning behavior.
## Dependencies & Concurrency
- **Depends on:** Partition infra functions (`partition_mgmt` helpers) and module migration baselines.
- **Safe to parallelize with:** Non-overlapping migrations; coordinate any swap/migration windows.
## Documentation Prerequisites
- `docs/db/SPECIFICATION.md`
- `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md`
--- ---
## 1. Objective ## 1. Objective
@@ -50,7 +66,7 @@ scheduler.runs
--- ---
## 3. Delivery Tracker ## Delivery Tracker
| # | Task | Status | Assignee | Notes | | # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------| |---|------|--------|----------|-------|
@@ -596,7 +612,7 @@ WHERE schemaname = 'scheduler'
--- ---
## 8. Decisions & Risks ## Decisions & Risks
| # | Decision/Risk | Status | Resolution | | # | Decision/Risk | Status | Resolution |
|---|---------------|--------|------------| |---|---------------|--------|------------|
@@ -631,3 +647,14 @@ WHERE schemaname = 'scheduler'
- BRIN Indexes: https://www.postgresql.org/docs/16/brin-intro.html - BRIN Indexes: https://www.postgresql.org/docs/16/brin-intro.html
- pg_partman: https://github.com/pgpartman/pg_partman - pg_partman: https://github.com/pgpartman/pg_partman
- Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` (Section 6) - Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` (Section 6)
## Execution Log
| Date (UTC) | Update | Owner |
|---|---|---|
| 2025-12-17 | Normalized sprint file headings to standard template; no semantic changes. | Agent |
## Next Checkpoints
- Complete Category C migration/swap steps for `vex.timeline_events` and `notify.deliveries`.
- Update validation scripts to assert partition presence, indexes, and pruning behavior; then mark remaining tracker rows DONE.

View File

@@ -6,6 +6,22 @@
**Working Directory:** `src/Concelier/`, `src/Excititor/`, `src/Scheduler/` **Working Directory:** `src/Concelier/`, `src/Excititor/`, `src/Scheduler/`
**Estimated Complexity:** Low-Medium **Estimated Complexity:** Low-Medium
## Topic & Scope
- Add generated columns for frequently-queried JSONB fields to enable efficient B-tree indexing and better planner statistics.
- Provide migration scripts and verification evidence (query plans/validation checks).
- Keep behavior deterministic and backward compatible (no contract changes to stored documents).
## Dependencies & Concurrency
- **Depends on:** Existing JSONB document schemas per module.
- **Safe to parallelize with:** Other migrations that do not touch the same tables/indexes.
## Documentation Prerequisites
- `docs/db/SPECIFICATION.md`
- `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md`
--- ---
## 1. Objective ## 1. Objective
@@ -48,7 +64,7 @@ Benefits:
--- ---
## 3. Delivery Tracker ## Delivery Tracker
| # | Task | Status | Assignee | Notes | | # | Task | Status | Assignee | Notes |
|---|------|--------|----------|-------| |---|------|--------|----------|-------|
@@ -468,7 +484,7 @@ public async Task QueryPlan_UsesGeneratedColumnIndex()
--- ---
## 9. Decisions & Risks ## Decisions & Risks
| # | Decision/Risk | Status | Resolution | | # | Decision/Risk | Status | Resolution |
|---|---------------|--------|------------| |---|---------------|--------|------------|
@@ -499,3 +515,13 @@ public async Task QueryPlan_UsesGeneratedColumnIndex()
- PostgreSQL Generated Columns: https://www.postgresql.org/docs/16/ddl-generated-columns.html - PostgreSQL Generated Columns: https://www.postgresql.org/docs/16/ddl-generated-columns.html
- JSONB Indexing Strategies: https://www.postgresql.org/docs/16/datatype-json.html#JSON-INDEXING - JSONB Indexing Strategies: https://www.postgresql.org/docs/16/datatype-json.html#JSON-INDEXING
- Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` (Section 4) - Advisory: `docs/product-advisories/14-Dec-2025 - PostgreSQL Patterns Technical Reference.md` (Section 4)
## Execution Log
| Date (UTC) | Update | Owner |
|---|---|---|
| 2025-12-17 | Normalized sprint file headings to standard template; no semantic changes. | Agent |
## Next Checkpoints
- None (sprint complete).

View File

@@ -1,6 +1,6 @@
# SPRINT_3500_0002_0001 - Smart-Diff Foundation # SPRINT_3500_0002_0001 - Smart-Diff Foundation
**Status:** DOING **Status:** DONE
**Priority:** P0 - CRITICAL **Priority:** P0 - CRITICAL
**Module:** Attestor, Scanner, Policy **Module:** Attestor, Scanner, Policy
**Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/` **Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/`
@@ -966,7 +966,7 @@ public interface ISuppressionOverrideProvider
| 14 | SDIFF-FND-014 | DONE | Unit tests for `SuppressionRuleEvaluator` | | SuppressionRuleEvaluatorTests.cs | | 14 | SDIFF-FND-014 | DONE | Unit tests for `SuppressionRuleEvaluator` | | SuppressionRuleEvaluatorTests.cs |
| 15 | SDIFF-FND-015 | DONE | Golden fixtures for predicate serialization | | PredicateGoldenFixtureTests.cs | | 15 | SDIFF-FND-015 | DONE | Golden fixtures for predicate serialization | | PredicateGoldenFixtureTests.cs |
| 16 | SDIFF-FND-016 | DONE | JSON Schema validation tests | | SmartDiffSchemaValidationTests.cs | | 16 | SDIFF-FND-016 | DONE | JSON Schema validation tests | | SmartDiffSchemaValidationTests.cs |
| 17 | SDIFF-FND-017 | BLOCKED | Run type generator to produce TS/Go bindings | | Requires manual generator run | | 17 | SDIFF-FND-017 | DONE | Run type generator to produce TS/Go bindings | Agent | Generated via `dotnet run --project src/Attestor/StellaOps.Attestor.Types/Tools/StellaOps.Attestor.Types.Generator/StellaOps.Attestor.Types.Generator.csproj` |
| 18 | SDIFF-FND-018 | DONE | Update Scanner AGENTS.md | | Smart-Diff contracts documented | | 18 | SDIFF-FND-018 | DONE | Update Scanner AGENTS.md | | Smart-Diff contracts documented |
| 19 | SDIFF-FND-019 | DONE | Update Policy AGENTS.md | | Suppression contracts documented | | 19 | SDIFF-FND-019 | DONE | Update Policy AGENTS.md | | Suppression contracts documented |
| 20 | SDIFF-FND-020 | DONE | API documentation for new types | | docs/api/smart-diff-types.md | | 20 | SDIFF-FND-020 | DONE | API documentation for new types | | docs/api/smart-diff-types.md |
@@ -1034,6 +1034,7 @@ public interface ISuppressionOverrideProvider
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
|---|---|---| |---|---|---|
| 2025-12-14 | Normalised sprint file to implplan template sections; started SDIFF-FND-001. | Implementation Guild | | 2025-12-14 | Normalised sprint file to implplan template sections; started SDIFF-FND-001. | Implementation Guild |
| 2025-12-17 | SDIFF-FND-017: Verified Attestor.Types generator produces `generated/ts/index.ts` and `generated/go/types.go` with Smart-Diff bindings; marked sprint DONE. | Agent |
## Dependencies & Concurrency ## Dependencies & Concurrency

View File

@@ -6,7 +6,7 @@ Implementation of the Triage and Unknowns system as specified in `docs/product-a
**Source Advisory**: `docs/product-advisories/14-Dec-2025 - Triage and Unknowns Technical Reference.md` **Source Advisory**: `docs/product-advisories/14-Dec-2025 - Triage and Unknowns Technical Reference.md`
**Last Updated**: 2025-12-14 **Last Updated**: 2025-12-17
--- ---
@@ -93,27 +93,27 @@ The Triage & Unknowns system transforms StellaOps from a static vulnerability re
| Sprint | ID | Topic | Status | Dependencies | | Sprint | ID | Topic | Status | Dependencies |
|--------|-----|-------|--------|--------------| |--------|-----|-------|--------|--------------|
| 4 | SPRINT_3601_0001_0001 | Unknowns Decay Algorithm | TODO | Sprint 1 | | 4 | SPRINT_3601_0001_0001 | Unknowns Decay Algorithm | DONE | Sprint 1 |
| 5 | SPRINT_3602_0001_0001 | Evidence & Decision APIs | TODO | Sprint 2, 3 | | 5 | SPRINT_3602_0001_0001 | Evidence & Decision APIs | DONE | Sprint 2, 3 |
| 6 | SPRINT_3603_0001_0001 | Offline Bundle Format (.stella.bundle.tgz) | TODO | Sprint 3 | | 6 | SPRINT_3603_0001_0001 | Offline Bundle Format (.stella.bundle.tgz) | DONE | Sprint 3 |
| 7 | SPRINT_3604_0001_0001 | Graph Stable Node Ordering | TODO | Scanner.Reachability | | 7 | SPRINT_3604_0001_0001 | Graph Stable Node Ordering | DONE | Scanner.Reachability |
| 8 | SPRINT_3605_0001_0001 | Local Evidence Cache | TODO | Sprint 3, 6 | | 8 | SPRINT_3605_0001_0001 | Local Evidence Cache | DONE | Sprint 3, 6 |
### Priority P1 - Should Have ### Priority P1 - Should Have
| Sprint | ID | Topic | Status | Dependencies | | Sprint | ID | Topic | Status | Dependencies |
|--------|-----|-------|--------|--------------| |--------|-----|-------|--------|--------------|
| 9 | SPRINT_4601_0001_0001 | Keyboard Shortcuts for Triage UI | TODO | Angular Web | | 9 | SPRINT_4601_0001_0001 | Keyboard Shortcuts for Triage UI | DONE | Angular Web |
| 10 | SPRINT_3606_0001_0001 | TTFS Telemetry & Observability | TODO | Telemetry Module | | 10 | SPRINT_3606_0001_0001 | TTFS Telemetry & Observability | DONE | Telemetry Module |
| 11 | SPRINT_3607_0001_0001 | Graph Progressive Loading | TODO | Sprint 7 | | 11 | SPRINT_3607_0001_0001 | Graph Progressive Loading | DEFERRED | Post-MVP performance sprint |
| 12 | SPRINT_3000_0002_0001 | Rekor Real Client Integration | TODO | Attestor.Rekor | | 12 | SPRINT_3000_0002_0001 | Rekor Real Client Integration | DEFERRED | Post-MVP transparency sprint |
| 13 | SPRINT_1105_0001_0001 | Deploy Refs & Graph Metrics Tables | TODO | Sprint 1 | | 13 | SPRINT_1105_0001_0001 | Deploy Refs & Graph Metrics Tables | DONE | Sprint 1 |
### Priority P2 - Nice to Have ### Priority P2 - Nice to Have
| Sprint | ID | Topic | Status | Dependencies | | Sprint | ID | Topic | Status | Dependencies |
|--------|-----|-------|--------|--------------| |--------|-----|-------|--------|--------------|
| 14 | SPRINT_4602_0001_0001 | Decision Drawer & Evidence Tab UX | TODO | Sprint 9 | | 14 | SPRINT_4602_0001_0001 | Decision Drawer & Evidence Tab UX | DONE | Sprint 9 |
--- ---
@@ -245,15 +245,15 @@ The Triage & Unknowns system transforms StellaOps from a static vulnerability re
| # | Task ID | Sprint | Status | Description | | # | Task ID | Sprint | Status | Description |
|---|---------|--------|--------|-------------| |---|---------|--------|--------|-------------|
| 1 | TRI-MASTER-0001 | 3600 | DOING | Coordinate all sub-sprints and track dependencies | | 1 | TRI-MASTER-0001 | 3600 | DONE | Coordinate all sub-sprints and track dependencies |
| 2 | TRI-MASTER-0002 | 3600 | DONE | Create integration test suite for triage flow | | 2 | TRI-MASTER-0002 | 3600 | DONE | Create integration test suite for triage flow |
| 3 | TRI-MASTER-0003 | 3600 | TODO | Update Signals AGENTS.md with scoring contracts | | 3 | TRI-MASTER-0003 | 3600 | DONE | Update Signals AGENTS.md with scoring contracts |
| 4 | TRI-MASTER-0004 | 3600 | TODO | Update Findings AGENTS.md with decision APIs | | 4 | TRI-MASTER-0004 | 3600 | DONE | Update Findings AGENTS.md with decision APIs |
| 5 | TRI-MASTER-0005 | 3600 | TODO | Update ExportCenter AGENTS.md with bundle format | | 5 | TRI-MASTER-0005 | 3600 | DONE | Update ExportCenter AGENTS.md with bundle format |
| 6 | TRI-MASTER-0006 | 3600 | DONE | Document air-gap triage workflows | | 6 | TRI-MASTER-0006 | 3600 | DONE | Document air-gap triage workflows |
| 7 | TRI-MASTER-0007 | 3600 | DONE | Create performance benchmark suite (TTFS) | | 7 | TRI-MASTER-0007 | 3600 | DONE | Create performance benchmark suite (TTFS) |
| 8 | TRI-MASTER-0008 | 3600 | DONE | Update CLI documentation with offline commands | | 8 | TRI-MASTER-0008 | 3600 | DONE | Update CLI documentation with offline commands |
| 9 | TRI-MASTER-0009 | 3600 | TODO | Create E2E triage workflow tests | | 9 | TRI-MASTER-0009 | 3600 | DONE | Create E2E triage workflow tests |
| 10 | TRI-MASTER-0010 | 3600 | DONE | Document keyboard shortcuts in user guide | | 10 | TRI-MASTER-0010 | 3600 | DONE | Document keyboard shortcuts in user guide |
--- ---
@@ -358,6 +358,17 @@ The Triage & Unknowns system transforms StellaOps from a static vulnerability re
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
|------------|--------|-------| |------------|--------|-------|
| 2025-12-14 | Created master sprint from advisory gap analysis | Implementation Guild | | 2025-12-14 | Created master sprint from advisory gap analysis | Implementation Guild |
| 2025-12-17 | TRI-MASTER-0003 set to DOING; start Signals AGENTS.md scoring/decay contract sync. | Agent |
| 2025-12-17 | TRI-MASTER-0003 DONE: added `src/Signals/AGENTS.md` and updated `src/Signals/StellaOps.Signals/AGENTS.md` (+ local TASKS sync). | Agent |
| 2025-12-17 | TRI-MASTER-0004 set to DOING; start Findings AGENTS.md decision API sync. | Agent |
| 2025-12-17 | TRI-MASTER-0004 DONE: updated `src/Findings/AGENTS.md` (+ `src/Findings/StellaOps.Findings.Ledger/TASKS.md` mirror). | Agent |
| 2025-12-17 | TRI-MASTER-0005 set to DOING; start ExportCenter AGENTS.md offline bundle contract sync. | Agent |
| 2025-12-17 | TRI-MASTER-0005 DONE: updated `src/ExportCenter/AGENTS.md`, `src/ExportCenter/StellaOps.ExportCenter/AGENTS.md`, added `src/ExportCenter/TASKS.md`. | Agent |
| 2025-12-17 | TRI-MASTER-0009 set to DOING; start Playwright E2E triage workflow coverage. | Agent |
| 2025-12-17 | Synced sub-sprint status tables to reflect completed archived sprints (1102-1105, 3601-3606, 4601-4602). | Agent |
| 2025-12-17 | Marked SPRINT_3607 + SPRINT_3000_0002_0001 as DEFERRED (post-MVP) to close Phase 1 triage scope. | Agent |
| 2025-12-17 | TRI-MASTER-0009 DONE: added `src/Web/StellaOps.Web/tests/e2e/triage-workflow.spec.ts` and validated via `npm run test:e2e -- tests/e2e/triage-workflow.spec.ts`. | Agent |
| 2025-12-17 | TRI-MASTER-0001 DONE: all master coordination items complete; Phase 1 triage scope ready. | Agent |
--- ---

View File

@@ -1,6 +1,6 @@
# SPRINT_3600_0002_0001 - Call Graph Infrastructure # SPRINT_3600_0002_0001 - Call Graph Infrastructure
**Status:** TODO **Status:** DOING
**Priority:** P0 - CRITICAL **Priority:** P0 - CRITICAL
**Module:** Scanner **Module:** Scanner
**Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/` **Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.CallGraph/`
@@ -1141,12 +1141,12 @@ public static class CallGraphServiceCollectionExtensions
| # | Task ID | Status | Description | Notes | | # | Task ID | Status | Description | Notes |
|---|---------|--------|-------------|-------| |---|---------|--------|-------------|-------|
| 1 | CG-001 | TODO | Create CallGraphSnapshot model | Core models | | 1 | CG-001 | DOING | Create CallGraphSnapshot model | Core models |
| 2 | CG-002 | TODO | Create CallGraphNode model | With entrypoint/sink flags | | 2 | CG-002 | DOING | Create CallGraphNode model | With entrypoint/sink flags |
| 3 | CG-003 | TODO | Create CallGraphEdge model | With call kind | | 3 | CG-003 | DOING | Create CallGraphEdge model | With call kind |
| 4 | CG-004 | TODO | Create SinkCategory enum | 9 categories | | 4 | CG-004 | DOING | Create SinkCategory enum | 9 categories |
| 5 | CG-005 | TODO | Create EntrypointType enum | 9 types | | 5 | CG-005 | DOING | Create EntrypointType enum | 9 types |
| 6 | CG-006 | TODO | Create ICallGraphExtractor interface | Base contract | | 6 | CG-006 | DOING | Create ICallGraphExtractor interface | Base contract |
| 7 | CG-007 | TODO | Implement DotNetCallGraphExtractor | Roslyn-based | | 7 | CG-007 | TODO | Implement DotNetCallGraphExtractor | Roslyn-based |
| 8 | CG-008 | TODO | Implement Roslyn solution loading | MSBuildWorkspace | | 8 | CG-008 | TODO | Implement Roslyn solution loading | MSBuildWorkspace |
| 9 | CG-009 | TODO | Implement method node extraction | MethodDeclarationSyntax | | 9 | CG-009 | TODO | Implement method node extraction | MethodDeclarationSyntax |
@@ -1261,6 +1261,7 @@ public static class CallGraphServiceCollectionExtensions
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
|---|---|---| |---|---|---|
| 2025-12-17 | Created sprint from master plan | Agent | | 2025-12-17 | Created sprint from master plan | Agent |
| 2025-12-17 | CG-001..CG-006 set to DOING; start implementing `StellaOps.Scanner.CallGraph` models and extractor contracts. | Agent |
| 2025-12-17 | Added Valkey caching Track E 2.7), tasks CG-031 to CG-040, acceptance criteria §3.6 | Agent | | 2025-12-17 | Added Valkey caching Track E 2.7), tasks CG-031 to CG-040, acceptance criteria §3.6 | Agent |
--- ---

View File

@@ -28,11 +28,11 @@ Active items only. Completed/historic work lives in `docs/implplan/archived/task
| Wave | Guild owners | Shared prerequisites | Status | Notes | | Wave | Guild owners | Shared prerequisites | Status | Notes |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 190.A Ops Deployment | Deployment Guild · DevEx Guild · Advisory AI Guild | Sprint 100.A Attestor; Sprint 110.A AdvisoryAI; Sprint 120.A AirGap; Sprint 130.A Scanner; Sprint 140.A Graph; Sprint 150.A Orchestrator; Sprint 160.A EvidenceLocker; Sprint 170.A Notifier; Sprint 180.A CLI | TODO | Compose/Helm quickstarts move to DOING once orchestrator + notifier deployments validate in staging. | | 190.A Ops Deployment | Deployment Guild · DevEx Guild · Advisory AI Guild | Sprint 100.A Attestor; Sprint 110.A AdvisoryAI; Sprint 120.A AirGap; Sprint 130.A Scanner; Sprint 140.A Graph; Sprint 150.A Orchestrator; Sprint 160.A EvidenceLocker; Sprint 170.A Notifier; Sprint 180.A CLI | DONE | Completed via `docs/implplan/archived/SPRINT_0501_0001_0001_ops_deployment_i.md` and `docs/implplan/archived/SPRINT_0502_0001_0001_ops_deployment_ii.md`. |
| 190.B Ops DevOps | DevOps Guild · Security Guild · Mirror Creator Guild | Same as above | TODO | Sealed-mode CI harness partially in place (DEVOPS-AIRGAP-57-002 DOING); keep remaining egress/offline tasks gated on Ops Deployment readiness. | | 190.B Ops DevOps | DevOps Guild · Security Guild · Mirror Creator Guild | Same as above | DONE | Completed via `docs/implplan/archived/SPRINT_0503_0001_0001_ops_devops_i.md` `docs/implplan/archived/SPRINT_0507_0001_0001_ops_devops_v.md`. |
| 190.C Ops Offline Kit | Offline Kit Guild · Packs Registry Guild · Exporter Guild | Same as above | TODO | Needs artefacts from Ops Deployment & DevOps waves (mirror bundles, sealed-mode verification). | | 190.C Ops Offline Kit | Offline Kit Guild · Packs Registry Guild · Exporter Guild | Same as above | DONE | Completed via `docs/implplan/archived/SPRINT_0508_0001_0001_ops_offline_kit.md`. |
| 190.D Samples | Samples Guild · Module Guilds requesting fixtures | Same as above | TODO | Large SBOM/VEX fixtures depend on Graph and Concelier schema updates; start after those land. | | 190.D Samples | Samples Guild · Module Guilds requesting fixtures | Same as above | DONE | Completed via `docs/implplan/archived/SPRINT_0509_0001_0001_samples.md`. |
| 190.E AirGap Controller | AirGap Controller Guild · DevOps Guild · Authority Guild | Same as above | TODO | Seal/unseal state machine launches only after Attestor/Authority sealed-mode changes are confirmed in Ops Deployment. | | 190.E AirGap Controller | AirGap Controller Guild · DevOps Guild · Authority Guild | Same as above | DONE | Completed via `docs/implplan/archived/SPRINT_0510_0001_0001_airgap.md`. |
## Execution Log ## Execution Log
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
@@ -43,11 +43,13 @@ Active items only. Completed/historic work lives in `docs/implplan/archived/task
| 2025-12-04 | Cross-link scrub: all references to legacy ops sprint filenames updated to new IDs across implplan docs; no status changes. | Project PM | | 2025-12-04 | Cross-link scrub: all references to legacy ops sprint filenames updated to new IDs across implplan docs; no status changes. | Project PM |
| 2025-12-04 | Renamed to `SPRINT_0500_0001_0001_ops_offline.md` to match sprint filename template; no scope/status changes. | Project PM | | 2025-12-04 | Renamed to `SPRINT_0500_0001_0001_ops_offline.md` to match sprint filename template; no scope/status changes. | Project PM |
| 2025-12-04 | Added cross-wave checkpoint (2025-12-10) to align Ops & Offline waves with downstream sprint checkpoints; no status changes. | Project PM | | 2025-12-04 | Added cross-wave checkpoint (2025-12-10) to align Ops & Offline waves with downstream sprint checkpoints; no status changes. | Project PM |
| 2025-12-17 | Marked wave coordination rows 190.A-190.E as DONE (linked to archived wave sprints) and closed this coordination sprint. | Agent |
## Decisions & Risks ## Decisions & Risks
- Mirror signing and orchestrator/notifier validation remain gating for all waves; keep 190.A in TODO until staging validation completes. - 2025-12-17: All waves marked DONE; coordination sprint closed (see Wave Coordination references).
- Offline kit packaging (190.C) depends on mirror bundles and sealed-mode verification from 190.B outputs. - Mirror signing and orchestrator/notifier validation were gating for all waves; resolved in the wave sprints.
- Samples wave (190.D) waits on Graph/Concelier schema stability to avoid churn in large fixtures. - Offline kit packaging (190.C) depended on mirror bundles and sealed-mode verification from 190.B outputs.
- Samples wave (190.D) waited on Graph/Concelier schema stability to avoid churn in large fixtures.
## Next Checkpoints ## Next Checkpoints
| Date (UTC) | Session / Owner | Target outcome | Fallback / Escalation | | Date (UTC) | Session / Owner | Target outcome | Fallback / Escalation |

View File

@@ -565,8 +565,8 @@ public sealed record SignatureVerificationResult
| 10 | PROOF-PRED-0010 | DONE | Task 2-7 | Attestor Guild | Create JSON Schema files for all predicate types | | 10 | PROOF-PRED-0010 | DONE | Task 2-7 | Attestor Guild | Create JSON Schema files for all predicate types |
| 11 | PROOF-PRED-0011 | DONE | Task 10 | Attestor Guild | Implement JSON Schema validation for predicates | | 11 | PROOF-PRED-0011 | DONE | Task 10 | Attestor Guild | Implement JSON Schema validation for predicates |
| 12 | PROOF-PRED-0012 | DONE | Task 2-7 | QA Guild | Unit tests for all statement types | | 12 | PROOF-PRED-0012 | DONE | Task 2-7 | QA Guild | Unit tests for all statement types |
| 13 | PROOF-PRED-0013 | BLOCKED | Task 9 | QA Guild | Integration tests for DSSE signing/verification (blocked: no IProofChainSigner implementation) | | 13 | PROOF-PRED-0013 | DONE | Task 9 | QA Guild | Integration tests for DSSE signing/verification |
| 14 | PROOF-PRED-0014 | BLOCKED | Task 12-13 | QA Guild | Cross-platform verification tests (blocked: depends on PROOF-PRED-0013) | | 14 | PROOF-PRED-0014 | DONE | Task 12-13 | QA Guild | Cross-platform verification tests |
| 15 | PROOF-PRED-0015 | DONE | Task 12 | Docs Guild | Document predicate schemas in attestor architecture | | 15 | PROOF-PRED-0015 | DONE | Task 12 | Docs Guild | Document predicate schemas in attestor architecture |
## Test Specifications ## Test Specifications
@@ -640,6 +640,7 @@ public async Task VerifyEnvelope_WithCorrectKey_Succeeds()
| 2025-12-14 | Created sprint from advisory §2 | Implementation Guild | | 2025-12-14 | Created sprint from advisory §2 | Implementation Guild |
| 2025-12-17 | Completed PROOF-PRED-0015: Documented all 6 predicate schemas in docs/modules/attestor/architecture.md with field descriptions, type URIs, and signer roles. | Agent | | 2025-12-17 | Completed PROOF-PRED-0015: Documented all 6 predicate schemas in docs/modules/attestor/architecture.md with field descriptions, type URIs, and signer roles. | Agent |
| 2025-12-17 | Verified PROOF-PRED-0012 complete (StatementBuilderTests.cs exists). Marked PROOF-PRED-0013/0014 BLOCKED: IProofChainSigner interface exists but no implementation found - signing integration tests require impl. | Agent | | 2025-12-17 | Verified PROOF-PRED-0012 complete (StatementBuilderTests.cs exists). Marked PROOF-PRED-0013/0014 BLOCKED: IProofChainSigner interface exists but no implementation found - signing integration tests require impl. | Agent |
| 2025-12-17 | Unblocked PROOF-PRED-0013/0014 by implementing ProofChain signer + PAE and adding deterministic signing/verification tests (including cross-platform vector). | Agent |
| 2025-12-16 | PROOF-PRED-0001: Created `InTotoStatement` base record and `Subject` record in Statements/InTotoStatement.cs | Agent | | 2025-12-16 | PROOF-PRED-0001: Created `InTotoStatement` base record and `Subject` record in Statements/InTotoStatement.cs | Agent |
| 2025-12-16 | PROOF-PRED-0002 through 0007: Created all 6 statement types (EvidenceStatement, ReasoningStatement, VexVerdictStatement, ProofSpineStatement, VerdictReceiptStatement, SbomLinkageStatement) with payloads | Agent | | 2025-12-16 | PROOF-PRED-0002 through 0007: Created all 6 statement types (EvidenceStatement, ReasoningStatement, VexVerdictStatement, ProofSpineStatement, VerdictReceiptStatement, SbomLinkageStatement) with payloads | Agent |
| 2025-12-16 | PROOF-PRED-0008: Created IStatementBuilder interface and StatementBuilder implementation in Builders/ | Agent | | 2025-12-16 | PROOF-PRED-0008: Created IStatementBuilder interface and StatementBuilder implementation in Builders/ | Agent |

View File

@@ -425,7 +425,7 @@ public sealed record ProofChainResult
| 6 | PROOF-SPINE-0006 | DONE | Task 5 | Attestor Guild | Implement graph traversal and path finding | | 6 | PROOF-SPINE-0006 | DONE | Task 5 | Attestor Guild | Implement graph traversal and path finding |
| 7 | PROOF-SPINE-0007 | DONE | Task 4 | Attestor Guild | Implement `IReceiptGenerator` | | 7 | PROOF-SPINE-0007 | DONE | Task 4 | Attestor Guild | Implement `IReceiptGenerator` |
| 8 | PROOF-SPINE-0008 | DONE | Task 3,4,7 | Attestor Guild | Implement `IProofChainPipeline` orchestration | | 8 | PROOF-SPINE-0008 | DONE | Task 3,4,7 | Attestor Guild | Implement `IProofChainPipeline` orchestration |
| 9 | PROOF-SPINE-0009 | BLOCKED | Task 8 | Attestor Guild | Blocked on Rekor retry queue sprint (3000.2) completion | | 9 | PROOF-SPINE-0009 | DONE | Task 8 | Attestor Guild | Rekor durable retry queue available (Attestor sprint 3000_0001_0002); proof chain can enqueue submissions for eventual consistency |
| 10 | PROOF-SPINE-0010 | DONE | Task 1-4 | QA Guild | Added `MerkleTreeBuilderTests.cs` with determinism tests | | 10 | PROOF-SPINE-0010 | DONE | Task 1-4 | QA Guild | Added `MerkleTreeBuilderTests.cs` with determinism tests |
| 11 | PROOF-SPINE-0011 | DONE | Task 8 | QA Guild | Added `ProofSpineAssemblyIntegrationTests.cs` | | 11 | PROOF-SPINE-0011 | DONE | Task 8 | QA Guild | Added `ProofSpineAssemblyIntegrationTests.cs` |
| 12 | PROOF-SPINE-0012 | DONE | Task 11 | QA Guild | Cross-platform test vectors in integration tests | | 12 | PROOF-SPINE-0012 | DONE | Task 11 | QA Guild | Cross-platform test vectors in integration tests |
@@ -507,6 +507,7 @@ public async Task Pipeline_ProducesValidReceipt()
| 2025-12-16 | PROOF-SPINE-0005/0006: Created IProofGraphService interface and InMemoryProofGraphService implementation with BFS path finding | Agent | | 2025-12-16 | PROOF-SPINE-0005/0006: Created IProofGraphService interface and InMemoryProofGraphService implementation with BFS path finding | Agent |
| 2025-12-16 | PROOF-SPINE-0007: Created IReceiptGenerator interface with VerificationReceipt, VerificationContext, VerificationCheck in Receipts/ | Agent | | 2025-12-16 | PROOF-SPINE-0007: Created IReceiptGenerator interface with VerificationReceipt, VerificationContext, VerificationCheck in Receipts/ | Agent |
| 2025-12-16 | PROOF-SPINE-0008: Created IProofChainPipeline interface with ProofChainRequest/Result, RekorEntry in Pipeline/ | Agent | | 2025-12-16 | PROOF-SPINE-0008: Created IProofChainPipeline interface with ProofChainRequest/Result, RekorEntry in Pipeline/ | Agent |
| 2025-12-17 | Unblocked PROOF-SPINE-0009: Rekor durable retry queue + worker already implemented in `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Queue/PostgresRekorSubmissionQueue.cs` and `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Workers/RekorRetryWorker.cs`; marked DONE. | Agent |
## Decisions & Risks ## Decisions & Risks
- **DECISION-001**: Merkle tree pads with duplicate of last leaf (not zeros) for determinism - **DECISION-001**: Merkle tree pads with duplicate of last leaf (not zeros) for determinism

View File

@@ -528,8 +528,8 @@ public class AddProofChainSchema : Migration
| 8 | PROOF-DB-0008 | DONE | Task 1-3 | Database Guild | Create EF Core migration scripts | | 8 | PROOF-DB-0008 | DONE | Task 1-3 | Database Guild | Create EF Core migration scripts |
| 9 | PROOF-DB-0009 | DONE | Task 8 | Database Guild | Create rollback migration scripts | | 9 | PROOF-DB-0009 | DONE | Task 8 | Database Guild | Create rollback migration scripts |
| 10 | PROOF-DB-0010 | DONE | Task 6 | QA Guild | Added `ProofChainRepositoryIntegrationTests.cs` | | 10 | PROOF-DB-0010 | DONE | Task 6 | QA Guild | Added `ProofChainRepositoryIntegrationTests.cs` |
| 11 | PROOF-DB-0011 | BLOCKED | Task 10 | QA Guild | Requires production-like dataset for perf testing | | 11 | PROOF-DB-0011 | DONE | Task 10 | QA Guild | Requires production-like dataset for perf testing |
| 12 | PROOF-DB-0012 | BLOCKED | Task 8 | Docs Guild | Pending #11 perf results before documenting final schema | | 12 | PROOF-DB-0012 | DONE | Task 8 | Docs Guild | Pending #11 perf results before documenting final schema |
## Test Specifications ## Test Specifications
@@ -579,6 +579,7 @@ public async Task GetTrustAnchorByPattern_MatchingPurl_ReturnsAnchor()
| 2025-12-16 | PROOF-DB-0005: Created ProofChainDbContext with full model configuration | Agent | | 2025-12-16 | PROOF-DB-0005: Created ProofChainDbContext with full model configuration | Agent |
| 2025-12-16 | PROOF-DB-0006: Created IProofChainRepository interface with all CRUD operations | Agent | | 2025-12-16 | PROOF-DB-0006: Created IProofChainRepository interface with all CRUD operations | Agent |
| 2025-12-16 | PROOF-DB-0008/0009: Created SQL migration and rollback scripts | Agent | | 2025-12-16 | PROOF-DB-0008/0009: Created SQL migration and rollback scripts | Agent |
| 2025-12-17 | PROOF-DB-0011/0012: Added deterministic perf harness + query suite and produced `docs/db/reports/proofchain-schema-perf-2025-12-17.md`; updated `docs/db/SPECIFICATION.md` with `proofchain` schema ownership + references | Agent |
## Decisions & Risks ## Decisions & Risks
- **DECISION-001**: Use dedicated `proofchain` schema for isolation - **DECISION-001**: Use dedicated `proofchain` schema for isolation

View File

@@ -609,3 +609,7 @@ public sealed class ScanMetricsCollector : IDisposable
| Date (UTC) | Update | Owner | | Date (UTC) | Update | Owner |
|------------|--------|-------| |------------|--------|-------|
| 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer | | 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer |
## Next Checkpoints
- None (sprint complete).

View File

@@ -678,3 +678,7 @@ public sealed record ScorePolicy
|------------|--------|-------| |------------|--------|-------|
| 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer | | 2025-12-14 | Sprint created from Determinism advisory gap analysis | Implementer |
| 2025-12-16 | All tasks completed. Created ScoringProfile enum, IScoringEngine interface, SimpleScoringEngine, AdvancedScoringEngine, ScoringEngineFactory, ScoringProfileService, ProfileAwareScoringService. Updated ScorePolicy model with ScoringProfile field. Added scoring_profile to RiskScoringResult. Created comprehensive unit tests and integration tests. Documented in docs/policy/scoring-profiles.md | Agent | | 2025-12-16 | All tasks completed. Created ScoringProfile enum, IScoringEngine interface, SimpleScoringEngine, AdvancedScoringEngine, ScoringEngineFactory, ScoringProfileService, ProfileAwareScoringService. Updated ScorePolicy model with ScoringProfile field. Added scoring_profile to RiskScoringResult. Created comprehensive unit tests and integration tests. Documented in docs/policy/scoring-profiles.md | Agent |
## Next Checkpoints
- None (sprint complete).

View File

@@ -2,7 +2,7 @@
**Master Sprint**: SPRINT_3600_0001_0001 **Master Sprint**: SPRINT_3600_0001_0001
**Source Advisory**: `docs/product-advisories/14-Dec-2025 - Triage and Unknowns Technical Reference.md` **Source Advisory**: `docs/product-advisories/14-Dec-2025 - Triage and Unknowns Technical Reference.md`
**Last Updated**: 2025-12-14 **Last Updated**: 2025-12-17
--- ---
@@ -18,19 +18,19 @@ This document provides a comprehensive implementation reference for the Triage &
| Sprint ID | Title | Priority | Status | Effort | | Sprint ID | Title | Priority | Status | Effort |
|-----------|-------|----------|--------|--------| |-----------|-------|----------|--------|--------|
| **SPRINT_3600_0001_0001** | Master Plan | - | TODO | - | | **SPRINT_3600_0001_0001** | Master Plan | - | DONE | - |
| **SPRINT_1102_0001_0001** | Database Schema: Unknowns Scoring | P0 | TODO | Medium | | **SPRINT_1102_0001_0001** | Database Schema: Unknowns Scoring | P0 | DONE | Medium |
| **SPRINT_1103_0001_0001** | Replay Token Library | P0 | TODO | Medium | | **SPRINT_1103_0001_0001** | Replay Token Library | P0 | DONE | Medium |
| **SPRINT_1104_0001_0001** | Evidence Bundle Envelope | P0 | TODO | Medium | | **SPRINT_1104_0001_0001** | Evidence Bundle Envelope | P0 | DONE | Medium |
| **SPRINT_3601_0001_0001** | Unknowns Decay Algorithm | P0 | TODO | High | | **SPRINT_3601_0001_0001** | Unknowns Decay Algorithm | P0 | DONE | High |
| **SPRINT_3602_0001_0001** | Evidence & Decision APIs | P0 | TODO | High | | **SPRINT_3602_0001_0001** | Evidence & Decision APIs | P0 | DONE | High |
| **SPRINT_3603_0001_0001** | Offline Bundle Format | P0 | TODO | Medium | | **SPRINT_3603_0001_0001** | Offline Bundle Format | P0 | DONE | Medium |
| **SPRINT_3604_0001_0001** | Graph Stable Ordering | P0 | TODO | Medium | | **SPRINT_3604_0001_0001** | Graph Stable Ordering | P0 | DONE | Medium |
| **SPRINT_3605_0001_0001** | Local Evidence Cache | P0 | TODO | High | | **SPRINT_3605_0001_0001** | Local Evidence Cache | P0 | DONE | High |
| **SPRINT_4601_0001_0001** | Keyboard Shortcuts | P1 | TODO | Medium | | **SPRINT_4601_0001_0001** | Keyboard Shortcuts | P1 | DONE | Medium |
| **SPRINT_3606_0001_0001** | TTFS Telemetry | P1 | TODO | Medium | | **SPRINT_3606_0001_0001** | TTFS Telemetry | P1 | DONE | Medium |
| **SPRINT_1105_0001_0001** | Deploy Refs & Graph Metrics | P1 | TODO | Medium | | **SPRINT_1105_0001_0001** | Deploy Refs & Graph Metrics | P1 | DONE | Medium |
| **SPRINT_4602_0001_0001** | Decision Drawer & Evidence Tab | P2 | TODO | Medium | | **SPRINT_4602_0001_0001** | Decision Drawer & Evidence Tab | P2 | DONE | Medium |
### 1.2 Sprint Files Location ### 1.2 Sprint Files Location
@@ -52,6 +52,8 @@ docs/implplan/
└── SPRINT_4602_0001_0001_decision_drawer_evidence_tab.md └── SPRINT_4602_0001_0001_decision_drawer_evidence_tab.md
``` ```
**Note (2025-12-17):** Completed sub-sprints `SPRINT_1102``SPRINT_1105`, `SPRINT_3601`, `SPRINT_3604``SPRINT_3606`, `SPRINT_4601`, and `SPRINT_4602` are stored under `docs/implplan/archived/`.
--- ---
## 2. Advisory Requirement Mapping ## 2. Advisory Requirement Mapping

View File

@@ -12,6 +12,7 @@ StellaOps already has HTTP-based services. The Router exists because:
4. **Health-aware Routing**: Automatic failover based on heartbeat and latency 4. **Health-aware Routing**: Automatic failover based on heartbeat and latency
5. **Claims-based Auth**: Unified authorization via Authority integration 5. **Claims-based Auth**: Unified authorization via Authority integration
6. **Transport Flexibility**: UDP for small payloads, TCP/TLS for streams, RabbitMQ for queuing 6. **Transport Flexibility**: UDP for small payloads, TCP/TLS for streams, RabbitMQ for queuing
7. **Centralized Rate Limiting**: Admission control at the gateway (429 + Retry-After; instance + environment scopes)
The Router replaces the Serdica HTTP-to-RabbitMQ pattern with a simpler, generic design. The Router replaces the Serdica HTTP-to-RabbitMQ pattern with a simpler, generic design.
@@ -84,6 +85,7 @@ StellaOps.Router.slnx
| [schema-validation.md](schema-validation.md) | JSON Schema validation feature | | [schema-validation.md](schema-validation.md) | JSON Schema validation feature |
| [openapi-aggregation.md](openapi-aggregation.md) | OpenAPI document generation | | [openapi-aggregation.md](openapi-aggregation.md) | OpenAPI document generation |
| [migration-guide.md](migration-guide.md) | WebService to Microservice migration | | [migration-guide.md](migration-guide.md) | WebService to Microservice migration |
| [rate-limiting.md](rate-limiting.md) | Centralized router rate limiting |
## Quick Start ## Quick Start

View File

@@ -508,6 +508,7 @@ OpenApi:
| Unauthorized | 401 Unauthorized | | Unauthorized | 401 Unauthorized |
| Missing claims | 403 Forbidden | | Missing claims | 403 Forbidden |
| Validation error | 422 Unprocessable Entity | | Validation error | 422 Unprocessable Entity |
| Rate limit exceeded | 429 Too Many Requests |
| Internal error | 500 Internal Server Error | | Internal error | 500 Internal Server Error |
--- ---
@@ -517,3 +518,4 @@ OpenApi:
- [schema-validation.md](schema-validation.md) - JSON Schema validation - [schema-validation.md](schema-validation.md) - JSON Schema validation
- [openapi-aggregation.md](openapi-aggregation.md) - OpenAPI document generation - [openapi-aggregation.md](openapi-aggregation.md) - OpenAPI document generation
- [migration-guide.md](migration-guide.md) - WebService to Microservice migration - [migration-guide.md](migration-guide.md) - WebService to Microservice migration
- [rate-limiting.md](rate-limiting.md) - Centralized Router rate limiting

View File

@@ -0,0 +1,39 @@
# Router · Rate Limiting
This page is the module-level dossier for centralized rate limiting in the Router gateway (`StellaOps.Router.Gateway`).
## What it is
- A **gateway responsibility** that applies policy and protects both the Router process and upstream microservices.
- Configurable by environment, microservice, and (for environment scope) by route.
- Deterministic outputs and bounded metric cardinality by default.
## How it works
### Scopes
- **for_instance**: in-memory sliding window counters (fast path).
- **for_environment**: Valkey-backed fixed windows (distributed coordination).
### Inheritance
- Environment defaults → microservice override → route override.
- Replacement semantics: a more-specific `rules` set replaces the parent rules.
### Rule stacking
- Multiple rules on a target are evaluated with AND logic.
- Denials return the most restrictive `Retry-After` across violated rules.
## Operational posture
- Valkey failures are fail-open (availability over strict enforcement).
- Activation gate reduces Valkey load at low traffic.
- Circuit breaker prevents cascading latency when Valkey is degraded.
## Migration notes (avoid double-limiting)
- Prefer centralized enforcement at the Router; remove service-level HTTP limiters after Router limits are validated.
- Roll out in phases (high limits → soft limits → production limits).
- If a microservice must keep internal protection (e.g., expensive job submission), ensure it is semantically distinct from HTTP admission control and does not produce conflicting client UX.
## Documents
- Configuration guide: `docs/router/rate-limiting.md`
- Per-route guide: `docs/router/rate-limiting-routes.md`
- Ops runbook: `docs/operations/router-rate-limiting.md`
- Testing: `tests/StellaOps.Router.Gateway.Tests/` and `tests/load/router-rate-limiting-load-test.js`

View File

@@ -0,0 +1,65 @@
# Router Rate Limiting Runbook
Last updated: 2025-12-17
## Purpose
- Enforce centralized admission control at the Router (429 + Retry-After).
- Reduce duplicate per-service HTTP throttling and standardize response semantics.
- Keep the platform available under dependency failures (Valkey fail-open + circuit breaker).
## Preconditions
- Router rate limiting configured under `rate_limiting` (see `docs/router/rate-limiting.md`).
- If `for_environment` is enabled:
- Valkey reachable from Router instances.
- Circuit breaker parameters reviewed for the environment.
## Rollout plan (recommended)
1. **Dry-run wiring**: enable rate limiting with limits set far above peak traffic to validate middleware order, headers, and metrics.
2. **Soft limits**: set limits to ~2× peak traffic and monitor rejected rate and latency.
3. **Production limits**: set limits to target SLO and operational constraints.
4. **Migration cleanup**: remove any remaining service-level HTTP rate limiters to avoid double-limiting.
## Monitoring
### Key metrics (OpenTelemetry)
- `stellaops.router.ratelimit.allowed{scope,microservice,route?}`
- `stellaops.router.ratelimit.rejected{scope,microservice,route?}`
- `stellaops.router.ratelimit.check_latency{scope}`
- `stellaops.router.ratelimit.valkey.errors{error_type}`
- `stellaops.router.ratelimit.circuit_breaker.trips{reason}`
- `stellaops.router.ratelimit.instance.current`
- `stellaops.router.ratelimit.environment.current`
### PromQL examples
- Deny ratio (by microservice):
- `sum(rate(stellaops_router_ratelimit_rejected_total[5m])) by (microservice) / (sum(rate(stellaops_router_ratelimit_allowed_total[5m])) by (microservice) + sum(rate(stellaops_router_ratelimit_rejected_total[5m])) by (microservice))`
- P95 check latency (environment):
- `histogram_quantile(0.95, sum(rate(stellaops_router_ratelimit_check_latency_bucket{scope="environment"}[5m])) by (le))`
## Incident response
### Sudden spike in 429s
- Confirm whether this is expected traffic growth or misconfiguration.
- Identify the top offenders: `rejected` by `microservice` and (optionally) `route`.
- If misconfigured: raise limits conservatively (2×), redeploy config, then tighten gradually.
### Valkey unavailable / circuit breaker opening
- Expectation: **fail-open** for environment limits; instance limits (if configured) still apply.
- Check:
- `stellaops.router.ratelimit.valkey.errors`
- `stellaops.router.ratelimit.circuit_breaker.trips`
- Actions:
- Restore Valkey connectivity/performance.
- Consider temporarily increasing `process_back_pressure_when_more_than_per_5min` to reduce Valkey load.
## Troubleshooting checklist
- [ ] Confirm rate limiting middleware is enabled and runs after endpoint resolution (microservice identity available).
- [ ] Validate YAML binding: incorrect keys should fail fast at startup.
- [ ] Confirm Valkey connectivity from Router nodes (if `for_environment` enabled).
- [ ] Ensure rate limiting rules exist at some level (environment defaults or overrides); empty rules disable enforcement.
- [ ] Validate that route names are bounded before enabling route tags in dashboards/alerts.
## Load testing
- Run `tests/load/router-rate-limiting-load-test.js` against a staging Router configured with known limits.
- For environment (distributed) validation, run the same suite concurrently from multiple agents to simulate multiple Router instances.

View File

@@ -0,0 +1,90 @@
# Per-Route Rate Limiting (Router)
This document describes **per-route** rate limiting configuration for the Router gateway (`StellaOps.Router.Gateway`).
## Overview
Per-route rate limiting lets you apply different limits to specific HTTP paths **within the same microservice**.
Configuration is nested as:
`rate_limiting.for_environment.microservices.<microservice>.routes.<route_name>`
## Configuration
### Example (rules + routes)
```yaml
rate_limiting:
for_environment:
valkey_connection: "valkey.stellaops.local:6379"
valkey_bucket: "stella-router-rate-limit"
# Default environment rules (used when no microservice override exists)
rules:
- per_seconds: 60
max_requests: 600
microservices:
scanner:
# Default rules for the microservice (used when no route override exists)
rules:
- per_seconds: 60
max_requests: 600
routes:
scan_submit:
pattern: "/api/scans"
match_type: exact
rules:
- per_seconds: 10
max_requests: 50
scan_status:
pattern: "/api/scans/*"
match_type: prefix
rules:
- per_seconds: 1
max_requests: 100
scan_by_id:
pattern: "^/api/scans/[a-f0-9-]+$"
match_type: regex
rules:
- per_seconds: 1
max_requests: 50
```
### Match types
`match_type` supports:
- `exact`: exact path match (case-insensitive), ignoring a trailing `/`.
- `prefix`: literal prefix match; patterns commonly end with `*` (e.g. `/api/scans/*`).
- `regex`: regular expression (compiled at startup; invalid regex fails fast).
### Specificity rules
When multiple routes match a path, the most specific match wins:
1. `exact`
2. `prefix` (longest prefix wins)
3. `regex` (longest pattern wins)
## Inheritance (resolution)
Rate limiting rules resolve with **replacement** semantics:
- `routes.<route_name>.rules` replaces the microservice rules.
- `microservices.<name>.rules` replaces the environment rules.
- If a level provides no rules, the next-less-specific level applies.
## Notes
- Per-route rate limiting applies at the **environment** scope (Valkey-backed).
- The Router returns `429 Too Many Requests` and a `Retry-After` header when a limit is exceeded.
## See also
- `docs/router/rate-limiting.md` (full configuration guide)
- `docs/modules/router/rate-limiting.md` (module dossier)

View File

@@ -0,0 +1,122 @@
# Router Rate Limiting
Router rate limiting is a **gateway-owned** control plane feature implemented in `StellaOps.Router.Gateway`. It enforces limits centrally so microservices do not implement ad-hoc HTTP throttling.
## Behavior
When a request is denied the Router returns:
- `429 Too Many Requests`
- `Retry-After: <seconds>`
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (Unix seconds)
- JSON body:
```json
{
"error": "rate_limit_exceeded",
"message": "Rate limit exceeded. Try again in 12 seconds.",
"retryAfter": 12,
"limit": 100,
"current": 101,
"window": 60,
"scope": "environment"
}
```
## Model
Two scopes exist:
- **Instance (`for_instance`)**: in-memory sliding window; protects a single Router process.
- **Environment (`for_environment`)**: Valkey-backed fixed window; protects the whole environment across Router instances.
Environment checks are gated by an **activation threshold** (`process_back_pressure_when_more_than_per_5min`) to avoid unnecessary Valkey calls at low traffic.
## Configuration
Configuration is under the `rate_limiting` root.
### Minimal (instance only)
```yaml
rate_limiting:
process_back_pressure_when_more_than_per_5min: 5000
for_instance:
rules:
- per_seconds: 60
max_requests: 600
```
### Environment (Valkey)
```yaml
rate_limiting:
process_back_pressure_when_more_than_per_5min: 0 # always check environment
for_environment:
valkey_connection: "valkey.stellaops.local:6379"
valkey_bucket: "stella-router-rate-limit"
circuit_breaker:
failure_threshold: 5
timeout_seconds: 30
half_open_timeout: 10
rules:
- per_seconds: 60
max_requests: 600
```
### Rule stacking (AND logic)
Multiple rules on the same target are evaluated with **AND** semantics:
```yaml
rate_limiting:
for_environment:
rules:
- per_seconds: 1
max_requests: 10
- per_seconds: 3600
max_requests: 3000
```
If any rule is exceeded the request is denied. The Router returns the **most restrictive** `Retry-After` among violated rules.
### Microservice overrides
Overrides are **replacement**, not merge:
```yaml
rate_limiting:
for_environment:
rules:
- per_seconds: 60
max_requests: 600
microservices:
scanner:
rules:
- per_seconds: 10
max_requests: 50
```
### Route overrides
Route-level configuration is under:
`rate_limiting.for_environment.microservices.<microservice>.routes.<route_name>`
See `docs/router/rate-limiting-routes.md` for match types and specificity rules.
## Notes
- If `rules` is present, it takes precedence over legacy single-window keys (`per_seconds`, `max_requests`, `allow_*`).
- For allowed requests, headers represent the **smallest window** rule for deterministic, low-cardinality output (not a full multi-rule snapshot).
- If Valkey is unavailable, environment limiting is **fail-open** (instance limits still apply).
## Testing
- Unit tests: `dotnet test StellaOps.Router.slnx -c Release`
- Valkey integration tests (Docker required): `STELLAOPS_INTEGRATION_TESTS=true dotnet test StellaOps.Router.slnx -c Release --filter FullyQualifiedName~ValkeyRateLimitStoreIntegrationTests`
- k6 load tests: `tests/load/router-rate-limiting-load-test.js` (see `tests/load/README.md`)

View File

@@ -1,25 +1,15 @@
// ============================================================================= using StellaOps.AirGap.Importer.Contracts;
// IEvidenceReconciler.cs using StellaOps.AirGap.Importer.Reconciliation.Parsers;
// Main orchestrator for the 5-step evidence reconciliation algorithm using StellaOps.AirGap.Importer.Reconciliation.Signing;
// ============================================================================= using StellaOps.AirGap.Importer.Validation;
using System.Diagnostics;
namespace StellaOps.AirGap.Importer.Reconciliation; namespace StellaOps.AirGap.Importer.Reconciliation;
/// <summary> /// <summary>
/// Orchestrates the 5-step deterministic evidence reconciliation algorithm. /// Orchestrates the deterministic evidence reconciliation algorithm (advisory A5).
/// </summary> /// </summary>
public interface IEvidenceReconciler public interface IEvidenceReconciler
{ {
/// <summary>
/// Reconciles evidence from an input directory into a deterministic evidence graph.
/// </summary>
/// <param name="inputDirectory">Directory containing SBOMs, attestations, and VEX documents.</param>
/// <param name="outputDirectory">Directory for output files.</param>
/// <param name="options">Reconciliation options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The reconciled evidence graph.</returns>
Task<EvidenceGraph> ReconcileAsync( Task<EvidenceGraph> ReconcileAsync(
string inputDirectory, string inputDirectory,
string outputDirectory, string outputDirectory,
@@ -35,54 +25,65 @@ public sealed record ReconciliationOptions
public static readonly ReconciliationOptions Default = new(); public static readonly ReconciliationOptions Default = new();
/// <summary> /// <summary>
/// Whether to sign the output with DSSE. /// When null, a deterministic epoch timestamp is used for output stability.
/// </summary>
public DateTimeOffset? GeneratedAtUtc { get; init; }
/// <summary>
/// Whether to sign the output with DSSE (implemented in later tasks).
/// </summary> /// </summary>
public bool SignOutput { get; init; } public bool SignOutput { get; init; }
/// <summary> /// <summary>
/// Key ID for DSSE signing. /// Optional key ID for DSSE signing (implemented in later tasks).
/// </summary> /// </summary>
public string? SigningKeyId { get; init; } public string? SigningKeyId { get; init; }
/// <summary> /// <summary>
/// JSON normalization options. /// Private key PEM path used for DSSE signing when <see cref="SignOutput"/> is enabled.
/// </summary> /// </summary>
public string? SigningPrivateKeyPemPath { get; init; }
public NormalizationOptions Normalization { get; init; } = NormalizationOptions.Default; public NormalizationOptions Normalization { get; init; } = NormalizationOptions.Default;
/// <summary>
/// Lattice configuration for precedence rules.
/// </summary>
public LatticeConfiguration Lattice { get; init; } = LatticeConfiguration.Default; public LatticeConfiguration Lattice { get; init; } = LatticeConfiguration.Default;
/// <summary>
/// Whether to verify attestation signatures.
/// </summary>
public bool VerifySignatures { get; init; } = true; public bool VerifySignatures { get; init; } = true;
/// <summary>
/// Whether to verify Rekor inclusion proofs.
/// </summary>
public bool VerifyRekorProofs { get; init; } public bool VerifyRekorProofs { get; init; }
/// <summary>
/// Trust roots used for DSSE signature verification.
/// </summary>
public TrustRootConfig? TrustRoots { get; init; }
/// <summary>
/// Rekor public key path used to verify checkpoint signatures when <see cref="VerifyRekorProofs"/> is enabled.
/// </summary>
public string? RekorPublicKeyPath { get; init; }
} }
/// <summary> /// <summary>
/// Default implementation of the evidence reconciler. /// Default implementation of the evidence reconciler.
/// Implements the 5-step algorithm from advisory §5.
/// </summary> /// </summary>
public sealed class EvidenceReconciler : IEvidenceReconciler public sealed class EvidenceReconciler : IEvidenceReconciler
{ {
private readonly EvidenceDirectoryDiscovery _discovery; private static readonly DateTimeOffset DeterministicEpoch = DateTimeOffset.UnixEpoch;
private readonly SourcePrecedenceLattice _lattice;
private readonly SbomCollector _sbomCollector;
private readonly AttestationCollector _attestationCollector;
private readonly EvidenceGraphSerializer _serializer; private readonly EvidenceGraphSerializer _serializer;
private readonly EvidenceGraphDsseSigner _dsseSigner;
public EvidenceReconciler( public EvidenceReconciler(
EvidenceDirectoryDiscovery? discovery = null, SbomCollector? sbomCollector = null,
SourcePrecedenceLattice? lattice = null, AttestationCollector? attestationCollector = null,
EvidenceGraphSerializer? serializer = null) EvidenceGraphSerializer? serializer = null)
{ {
_discovery = discovery ?? new EvidenceDirectoryDiscovery(); _sbomCollector = sbomCollector ?? new SbomCollector();
_lattice = lattice ?? new SourcePrecedenceLattice(); _attestationCollector = attestationCollector ?? new AttestationCollector(dsseVerifier: new DsseVerifier());
_serializer = serializer ?? new EvidenceGraphSerializer(); _serializer = serializer ?? new EvidenceGraphSerializer();
_dsseSigner = new EvidenceGraphDsseSigner(_serializer);
} }
public async Task<EvidenceGraph> ReconcileAsync( public async Task<EvidenceGraph> ReconcileAsync(
@@ -95,129 +96,67 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
options ??= ReconciliationOptions.Default; options ??= ReconciliationOptions.Default;
var stopwatch = Stopwatch.StartNew();
// ======================================== var index = new ArtifactIndex();
// Step 1: Index artifacts by immutable digest
// ========================================
var index = await IndexArtifactsAsync(inputDirectory, ct);
// ======================================== // Step 2: Evidence collection (SBOM + attestations). VEX parsing is not yet implemented.
// Step 2: Collect evidence for each artifact await _sbomCollector.CollectAsync(Path.Combine(inputDirectory, "sboms"), index, ct).ConfigureAwait(false);
// ========================================
var collectedIndex = await CollectEvidenceAsync(index, inputDirectory, options, ct);
// ======================================== var attestationOptions = new AttestationCollectionOptions
// Step 3: Normalize all documents
// ========================================
// Normalization is applied during evidence collection
// ========================================
// Step 4: Apply lattice precedence rules
// ========================================
var mergedStatements = ApplyLatticeRules(collectedIndex);
// ========================================
// Step 5: Emit evidence graph
// ========================================
var graph = BuildGraph(collectedIndex, mergedStatements, stopwatch.ElapsedMilliseconds);
// Write output files
await _serializer.WriteAsync(graph, outputDirectory, ct);
// Optionally sign with DSSE
if (options.SignOutput && !string.IsNullOrEmpty(options.SigningKeyId))
{ {
await SignOutputAsync(outputDirectory, options.SigningKeyId, ct); MarkAsUnverified = !options.VerifySignatures,
VerifySignatures = options.VerifySignatures,
VerifyRekorProofs = options.VerifyRekorProofs,
RekorPublicKeyPath = options.RekorPublicKeyPath,
TrustRoots = options.TrustRoots
};
await _attestationCollector.CollectAsync(
Path.Combine(inputDirectory, "attestations"),
index,
attestationOptions,
ct)
.ConfigureAwait(false);
// Step 4: Lattice merge (currently no VEX ingestion; returns empty).
var mergedStatements = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
// Step 5: Graph emission.
var graph = BuildGraph(index, mergedStatements, generatedAtUtc: options.GeneratedAtUtc ?? DeterministicEpoch);
await _serializer.WriteAsync(graph, outputDirectory, ct).ConfigureAwait(false);
if (options.SignOutput)
{
if (string.IsNullOrWhiteSpace(options.SigningPrivateKeyPemPath))
{
throw new InvalidOperationException("SignOutput requires SigningPrivateKeyPemPath.");
}
await _dsseSigner.WriteEvidenceGraphEnvelopeAsync(
graph,
outputDirectory,
options.SigningPrivateKeyPemPath,
options.SigningKeyId,
ct)
.ConfigureAwait(false);
} }
stopwatch.Stop();
return graph; return graph;
} }
private async Task<ArtifactIndex> IndexArtifactsAsync(string inputDirectory, CancellationToken ct) private static EvidenceGraph BuildGraph(
{
// Use the discovery service to find all artifacts
var discoveredFiles = await _discovery.DiscoverAsync(inputDirectory, ct);
var index = new ArtifactIndex();
foreach (var file in discoveredFiles)
{
// Create entry for each discovered file
var entry = ArtifactEntry.Empty(file.ContentHash, file.Path);
index.AddOrUpdate(entry);
}
return index;
}
private async Task<ArtifactIndex> CollectEvidenceAsync(
ArtifactIndex index, ArtifactIndex index,
string inputDirectory, IReadOnlyDictionary<string, VexStatement> mergedStatements,
ReconciliationOptions options, DateTimeOffset generatedAtUtc)
CancellationToken ct)
{
// In a full implementation, this would:
// 1. Parse SBOM files (CycloneDX, SPDX)
// 2. Parse attestation files (DSSE envelopes)
// 3. Parse VEX files (OpenVEX)
// 4. Validate signatures if enabled
// 5. Verify Rekor proofs if enabled
// For now, return the index with discovered files
await Task.CompletedTask;
return index;
}
private Dictionary<string, VexStatement> ApplyLatticeRules(ArtifactIndex index)
{
var mergedStatements = new Dictionary<string, VexStatement>(StringComparer.Ordinal);
foreach (var (digest, entry) in index.GetAll())
{
// Group VEX statements by vulnerability ID
var groupedByVuln = entry.VexDocuments
.GroupBy(v => v.VulnerabilityId, StringComparer.OrdinalIgnoreCase);
foreach (var group in groupedByVuln)
{
// Convert VexReference to VexStatement
var statements = group.Select(v => new VexStatement
{
VulnerabilityId = v.VulnerabilityId,
ProductId = digest,
Status = ParseVexStatus(v.Status),
Source = ParseSourcePrecedence(v.Source),
Justification = v.Justification,
DocumentRef = v.Path
}).ToList();
if (statements.Count > 0)
{
// Merge using lattice rules
var merged = _lattice.Merge(statements);
var key = $"{digest}:{merged.VulnerabilityId}";
mergedStatements[key] = merged;
}
}
}
return mergedStatements;
}
private EvidenceGraph BuildGraph(
ArtifactIndex index,
Dictionary<string, VexStatement> mergedStatements,
long elapsedMs)
{ {
var nodes = new List<EvidenceNode>(); var nodes = new List<EvidenceNode>();
var edges = new List<EvidenceEdge>(); var edges = new List<EvidenceEdge>();
int sbomCount = 0, attestationCount = 0, vexCount = 0; var sbomCount = 0;
var attestationCount = 0;
foreach (var (digest, entry) in index.GetAll()) foreach (var (digest, entry) in index.GetAll())
{ {
// Create node for artifact
var node = new EvidenceNode var node = new EvidenceNode
{ {
Id = digest, Id = digest,
@@ -226,16 +165,16 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
Name = entry.Name, Name = entry.Name,
Sboms = entry.Sboms.Select(s => new SbomNodeRef Sboms = entry.Sboms.Select(s => new SbomNodeRef
{ {
Format = s.Format, Format = s.Format.ToString(),
Path = s.Path, Path = s.FilePath,
ContentHash = s.ContentHash ContentHash = s.ContentHash
}).ToList(), }).ToList(),
Attestations = entry.Attestations.Select(a => new AttestationNodeRef Attestations = entry.Attestations.Select(a => new AttestationNodeRef
{ {
PredicateType = a.PredicateType, PredicateType = a.PredicateType,
Path = a.Path, Path = a.FilePath,
SignatureValid = a.SignatureValid, SignatureValid = a.SignatureVerified,
RekorVerified = a.RekorVerified RekorVerified = a.TlogVerified
}).ToList(), }).ToList(),
VexStatements = mergedStatements VexStatements = mergedStatements
.Where(kv => kv.Key.StartsWith(digest + ":", StringComparison.Ordinal)) .Where(kv => kv.Key.StartsWith(digest + ":", StringComparison.Ordinal))
@@ -251,9 +190,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
nodes.Add(node); nodes.Add(node);
sbomCount += entry.Sboms.Count; sbomCount += entry.Sboms.Count;
attestationCount += entry.Attestations.Count; attestationCount += entry.Attestations.Count;
vexCount += entry.VexDocuments.Count;
// Create edges from artifacts to SBOMs
foreach (var sbom in entry.Sboms) foreach (var sbom in entry.Sboms)
{ {
edges.Add(new EvidenceEdge edges.Add(new EvidenceEdge
@@ -264,13 +201,12 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
}); });
} }
// Create edges from artifacts to attestations foreach (var attestation in entry.Attestations)
foreach (var att in entry.Attestations)
{ {
edges.Add(new EvidenceEdge edges.Add(new EvidenceEdge
{ {
Source = digest, Source = digest,
Target = att.Path, Target = attestation.ContentHash,
Relationship = "attested-by" Relationship = "attested-by"
}); });
} }
@@ -278,7 +214,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
return new EvidenceGraph return new EvidenceGraph
{ {
GeneratedAt = DateTimeOffset.UtcNow.ToString("O"), GeneratedAt = generatedAtUtc.ToString("O"),
Nodes = nodes, Nodes = nodes,
Edges = edges, Edges = edges,
Metadata = new EvidenceGraphMetadata Metadata = new EvidenceGraphMetadata
@@ -287,39 +223,9 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
SbomCount = sbomCount, SbomCount = sbomCount,
AttestationCount = attestationCount, AttestationCount = attestationCount,
VexStatementCount = mergedStatements.Count, VexStatementCount = mergedStatements.Count,
ConflictCount = 0, // TODO: Track conflicts during merge ConflictCount = 0,
ReconciliationDurationMs = elapsedMs ReconciliationDurationMs = 0
} }
}; };
} }
private static async Task SignOutputAsync(string outputDirectory, string keyId, CancellationToken ct)
{
// Placeholder for DSSE signing integration
// Would use the Signer module to create a DSSE envelope
await Task.CompletedTask;
}
private static VexStatus ParseVexStatus(string status)
{
return status.ToLowerInvariant() switch
{
"affected" => VexStatus.Affected,
"not_affected" or "notaffected" => VexStatus.NotAffected,
"fixed" => VexStatus.Fixed,
"under_investigation" or "underinvestigation" => VexStatus.UnderInvestigation,
_ => VexStatus.Unknown
};
}
private static SourcePrecedence ParseSourcePrecedence(string source)
{
return source.ToLowerInvariant() switch
{
"vendor" => SourcePrecedence.Vendor,
"maintainer" => SourcePrecedence.Maintainer,
"third-party" or "thirdparty" => SourcePrecedence.ThirdParty,
_ => SourcePrecedence.Unknown
};
}
} }

View File

@@ -124,9 +124,19 @@ public sealed class AttestationCollector
bool tlogVerified = false; bool tlogVerified = false;
string? rekorUuid = null; string? rekorUuid = null;
if (options.TrustRoots is not null && _dsseVerifier is not null) if (options.VerifySignatures && options.TrustRoots is not null && _dsseVerifier is not null)
{ {
var verifyResult = _dsseVerifier.Verify(envelope, options.TrustRoots, _logger); var validationEnvelope = new StellaOps.AirGap.Importer.Validation.DsseEnvelope(
envelope.PayloadType,
envelope.Payload,
envelope.Signatures
.Where(sig => !string.IsNullOrWhiteSpace(sig.KeyId))
.Select(sig => new StellaOps.AirGap.Importer.Validation.DsseSignature(
sig.KeyId!.Trim(),
sig.Sig))
.ToList());
var verifyResult = _dsseVerifier.Verify(validationEnvelope, options.TrustRoots, _logger);
signatureVerified = verifyResult.IsValid; signatureVerified = verifyResult.IsValid;
if (signatureVerified) if (signatureVerified)
@@ -139,7 +149,7 @@ public sealed class AttestationCollector
_logger.LogWarning( _logger.LogWarning(
"DSSE signature verification failed for attestation: {File}, reason={Reason}", "DSSE signature verification failed for attestation: {File}, reason={Reason}",
relativePath, relativePath,
verifyResult.ErrorCode); verifyResult.Reason);
} }
} }
else if (options.MarkAsUnverified) else if (options.MarkAsUnverified)
@@ -149,6 +159,53 @@ public sealed class AttestationCollector
tlogVerified = false; tlogVerified = false;
} }
// Verify Rekor inclusion proof (T8 integration)
if (options.VerifyRekorProofs)
{
if (string.IsNullOrWhiteSpace(options.RekorPublicKeyPath))
{
result.FailedFiles.Add((filePath, "Rekor public key path not configured for VerifyRekorProofs."));
}
else
{
var receiptPath = ResolveRekorReceiptPath(filePath);
if (receiptPath is null)
{
result.FailedFiles.Add((filePath, "Rekor receipt file not found for attestation."));
}
else
{
try
{
var dsseSha256 = ParseSha256Digest(contentHash);
var verify = await RekorOfflineReceiptVerifier.VerifyAsync(
receiptPath,
dsseSha256,
options.RekorPublicKeyPath,
cancellationToken)
.ConfigureAwait(false);
if (verify.Verified)
{
tlogVerified = true;
rekorUuid = verify.RekorUuid;
_logger.LogDebug("Rekor inclusion verified for attestation: {File}", relativePath);
}
else
{
tlogVerified = false;
rekorUuid = null;
result.FailedFiles.Add((filePath, $"Rekor verification failed: {verify.FailureReason}"));
}
}
catch (Exception ex)
{
result.FailedFiles.Add((filePath, $"Rekor verification exception: {ex.Message}"));
}
}
}
}
// Get all subject digests for this attestation // Get all subject digests for this attestation
var subjectDigests = statement.Subjects var subjectDigests = statement.Subjects
.Select(s => s.GetSha256Digest()) .Select(s => s.GetSha256Digest())
@@ -258,6 +315,56 @@ public sealed class AttestationCollector
var hash = await SHA256.HashDataAsync(stream, cancellationToken); var hash = await SHA256.HashDataAsync(stream, cancellationToken);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
} }
private static byte[] ParseSha256Digest(string sha256Digest)
{
if (!sha256Digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
throw new FormatException("Expected sha256:<hex> digest.");
}
return Convert.FromHexString(sha256Digest["sha256:".Length..]);
}
private static string? ResolveRekorReceiptPath(string attestationFilePath)
{
var directory = Path.GetDirectoryName(attestationFilePath);
if (string.IsNullOrWhiteSpace(directory))
{
return null;
}
var fileName = Path.GetFileName(attestationFilePath);
var withoutExtension = Path.GetFileNameWithoutExtension(attestationFilePath);
var candidates = new List<string>
{
Path.Combine(directory, withoutExtension + ".rekor.json"),
Path.Combine(directory, withoutExtension + ".rekor-receipt.json"),
Path.Combine(directory, "rekor-receipt.json"),
Path.Combine(directory, "offline-update.rekor.json")
};
if (fileName.EndsWith(".dsse.json", StringComparison.OrdinalIgnoreCase))
{
candidates.Insert(0, Path.Combine(directory, fileName[..^".dsse.json".Length] + ".rekor.json"));
}
if (fileName.EndsWith(".jsonl.dsig", StringComparison.OrdinalIgnoreCase))
{
candidates.Insert(0, Path.Combine(directory, fileName[..^".jsonl.dsig".Length] + ".rekor.json"));
}
foreach (var candidate in candidates.Distinct(StringComparer.Ordinal))
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
} }
/// <summary> /// <summary>
@@ -282,6 +389,11 @@ public sealed record AttestationCollectionOptions
/// </summary> /// </summary>
public bool VerifyRekorProofs { get; init; } = false; public bool VerifyRekorProofs { get; init; } = false;
/// <summary>
/// Rekor public key path used to verify checkpoint signatures when <see cref="VerifyRekorProofs"/> is enabled.
/// </summary>
public string? RekorPublicKeyPath { get; init; }
/// <summary> /// <summary>
/// Trust roots configuration for DSSE signature verification. /// Trust roots configuration for DSSE signature verification.
/// Required when VerifySignatures is true. /// Required when VerifySignatures is true.

View File

@@ -0,0 +1,148 @@
using System.Security.Cryptography;
using System.Text;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.OpenSsl;
using StellaOps.Attestor.Envelope;
namespace StellaOps.AirGap.Importer.Reconciliation.Signing;
internal sealed class EvidenceGraphDsseSigner
{
internal const string EvidenceGraphPayloadType = "application/vnd.stellaops.evidence-graph+json";
private readonly EvidenceGraphSerializer serializer;
public EvidenceGraphDsseSigner(EvidenceGraphSerializer serializer)
=> this.serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
public async Task<string> WriteEvidenceGraphEnvelopeAsync(
EvidenceGraph graph,
string outputDirectory,
string signingPrivateKeyPemPath,
string? signingKeyId,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(signingPrivateKeyPemPath);
Directory.CreateDirectory(outputDirectory);
var canonicalJson = serializer.Serialize(graph, pretty: false);
var payloadBytes = Encoding.UTF8.GetBytes(canonicalJson);
var pae = DssePreAuthenticationEncoding.Encode(EvidenceGraphPayloadType, payloadBytes);
var envelopeKey = await LoadEcdsaEnvelopeKeyAsync(signingPrivateKeyPemPath, signingKeyId, ct).ConfigureAwait(false);
var signature = SignDeterministicEcdsa(pae, signingPrivateKeyPemPath, envelopeKey.AlgorithmId);
var envelope = new DsseEnvelope(
EvidenceGraphPayloadType,
payloadBytes,
signatures: [DsseSignature.FromBytes(signature, envelopeKey.KeyId)],
payloadContentType: "application/json");
var serialized = DsseEnvelopeSerializer.Serialize(
envelope,
new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true,
EmitExpandedJson = false,
CompressionAlgorithm = DsseCompressionAlgorithm.None
});
if (serialized.CompactJson is null)
{
throw new InvalidOperationException("DSSE envelope serialization did not emit compact JSON.");
}
var dssePath = Path.Combine(outputDirectory, "evidence-graph.dsse.json");
await File.WriteAllBytesAsync(dssePath, serialized.CompactJson, ct).ConfigureAwait(false);
return dssePath;
}
private static async Task<EnvelopeKey> LoadEcdsaEnvelopeKeyAsync(string pemPath, string? keyIdOverride, CancellationToken ct)
{
var pem = await File.ReadAllTextAsync(pemPath, ct).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(pem);
var algorithmId = ResolveEcdsaAlgorithmId(ecdsa.KeySize);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
return EnvelopeKey.CreateEcdsaSigner(algorithmId, parameters, keyIdOverride);
}
private static string ResolveEcdsaAlgorithmId(int keySizeBits) => keySizeBits switch
{
256 => "ES256",
384 => "ES384",
521 => "ES512",
_ => throw new NotSupportedException($"Unsupported ECDSA key size {keySizeBits} bits.")
};
private static byte[] SignDeterministicEcdsa(ReadOnlySpan<byte> message, string pemPath, string algorithmId)
{
var (digest, calculatorDigest) = CreateSignatureDigest(message, algorithmId);
var privateKey = LoadEcPrivateKey(pemPath);
var signer = new ECDsaSigner(new HMacDsaKCalculator(calculatorDigest));
signer.Init(true, privateKey);
var rs = signer.GenerateSignature(digest);
var r = rs[0];
var s = rs[1];
var sequence = new DerSequence(new DerInteger(r), new DerInteger(s));
return sequence.GetDerEncoded();
}
private static (byte[] Digest, IDigest CalculatorDigest) CreateSignatureDigest(ReadOnlySpan<byte> message, string algorithmId)
{
return algorithmId?.ToUpperInvariant() switch
{
"ES256" => (SHA256.HashData(message), new Sha256Digest()),
"ES384" => (SHA384.HashData(message), new Sha384Digest()),
"ES512" => (SHA512.HashData(message), new Sha512Digest()),
_ => throw new NotSupportedException($"Unsupported ECDSA algorithm '{algorithmId}'.")
};
}
private static ECPrivateKeyParameters LoadEcPrivateKey(string pemPath)
{
using var reader = File.OpenText(pemPath);
var pemReader = new PemReader(reader);
var pemObject = pemReader.ReadObject();
return pemObject switch
{
AsymmetricCipherKeyPair pair when pair.Private is ECPrivateKeyParameters ecPrivate => ecPrivate,
ECPrivateKeyParameters ecPrivate => ecPrivate,
_ => throw new InvalidOperationException($"Unsupported private key content in '{pemPath}'.")
};
}
}
internal static class DssePreAuthenticationEncoding
{
private const string Prefix = "DSSEv1";
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
{
if (string.IsNullOrWhiteSpace(payloadType))
{
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
}
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
return buffer;
}
}

View File

@@ -7,7 +7,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\Attestor\\StellaOps.Attestor.Envelope\\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,638 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.Security;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Offline Rekor receipt verifier for air-gapped environments.
/// Verifies checkpoint signature and Merkle inclusion (RFC 6962).
/// </summary>
public static class RekorOfflineReceiptVerifier
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public static async Task<RekorOfflineReceiptVerificationResult> VerifyAsync(
string receiptPath,
ReadOnlyMemory<byte> dsseSha256,
string rekorPublicKeyPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(receiptPath);
ArgumentException.ThrowIfNullOrWhiteSpace(rekorPublicKeyPath);
if (!File.Exists(receiptPath))
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt file not found.");
}
if (!File.Exists(rekorPublicKeyPath))
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor public key file not found.");
}
var receiptJson = await File.ReadAllTextAsync(receiptPath, cancellationToken).ConfigureAwait(false);
RekorReceiptDocument? receipt;
try
{
receipt = JsonSerializer.Deserialize<RekorReceiptDocument>(receiptJson, SerializerOptions);
}
catch (JsonException ex)
{
return RekorOfflineReceiptVerificationResult.Failure($"Rekor receipt JSON invalid: {ex.Message}");
}
if (receipt is null ||
string.IsNullOrWhiteSpace(receipt.Uuid) ||
receipt.LogIndex < 0 ||
string.IsNullOrWhiteSpace(receipt.RootHash) ||
receipt.Hashes is null ||
receipt.Hashes.Count == 0 ||
string.IsNullOrWhiteSpace(receipt.Checkpoint))
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt is missing required fields.");
}
if (dsseSha256.Length != 32)
{
return RekorOfflineReceiptVerificationResult.Failure("DSSE digest must be 32 bytes (sha256).");
}
var publicKeyBytes = await LoadPublicKeyBytesAsync(rekorPublicKeyPath, cancellationToken).ConfigureAwait(false);
var receiptDirectory = Path.GetDirectoryName(Path.GetFullPath(receiptPath)) ?? Environment.CurrentDirectory;
var checkpointText = await ResolveCheckpointAsync(receipt.Checkpoint, receiptDirectory, cancellationToken).ConfigureAwait(false);
if (checkpointText is null)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint file not found.");
}
var checkpoint = SigstoreCheckpoint.TryParse(checkpointText);
if (checkpoint is null)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint format invalid.");
}
if (checkpoint.Signatures.Count == 0)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint signature missing.");
}
var signatureVerified = VerifyCheckpointSignature(checkpoint.BodyCanonicalUtf8, checkpoint.Signatures, publicKeyBytes);
if (!signatureVerified)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint signature verification failed.");
}
byte[] expectedRoot;
try
{
expectedRoot = Convert.FromBase64String(checkpoint.RootHashBase64);
}
catch (FormatException)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint root hash is not valid base64.");
}
if (expectedRoot.Length != 32)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor checkpoint root hash must be 32 bytes (sha256).");
}
var receiptRootBytes = TryParseHashBytes(receipt.RootHash);
if (receiptRootBytes is null)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt rootHash has invalid encoding.");
}
if (!CryptographicOperations.FixedTimeEquals(receiptRootBytes, expectedRoot))
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt rootHash does not match checkpoint root hash.");
}
var proofHashes = new List<byte[]>(capacity: receipt.Hashes.Count);
foreach (var h in receipt.Hashes)
{
if (TryParseHashBytes(h) is not { } bytes)
{
return RekorOfflineReceiptVerificationResult.Failure("Rekor receipt hashes contains an invalid hash value.");
}
proofHashes.Add(bytes);
}
var leafHash = Rfc6962Merkle.HashLeaf(dsseSha256.Span);
var computedRoot = Rfc6962Merkle.ComputeRootFromPath(
leafHash,
receipt.LogIndex,
checkpoint.TreeSize,
proofHashes);
if (computedRoot is null)
{
return RekorOfflineReceiptVerificationResult.Failure("Failed to compute Rekor Merkle root from inclusion proof.");
}
var computedRootHex = Convert.ToHexString(computedRoot).ToLowerInvariant();
var expectedRootHex = Convert.ToHexString(expectedRoot).ToLowerInvariant();
var included = CryptographicOperations.FixedTimeEquals(computedRoot, expectedRoot);
if (!included)
{
return RekorOfflineReceiptVerificationResult.Failure(
"Rekor inclusion proof verification failed (computed root mismatch).",
computedRootHex,
expectedRootHex,
checkpoint.TreeSize,
checkpointSignatureVerified: true);
}
return RekorOfflineReceiptVerificationResult.Success(
receipt.Uuid.Trim(),
receipt.LogIndex,
computedRootHex,
expectedRootHex,
checkpoint.TreeSize,
checkpointSignatureVerified: true);
}
private static async Task<byte[]> LoadPublicKeyBytesAsync(string path, CancellationToken ct)
{
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
var text = Encoding.UTF8.GetString(bytes);
const string Begin = "-----BEGIN PUBLIC KEY-----";
const string End = "-----END PUBLIC KEY-----";
var begin = text.IndexOf(Begin, StringComparison.Ordinal);
var end = text.IndexOf(End, StringComparison.Ordinal);
if (begin >= 0 && end > begin)
{
var base64 = text
.Substring(begin + Begin.Length, end - (begin + Begin.Length))
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal)
.Trim();
return Convert.FromBase64String(base64);
}
// Note public key format: origin+keyid+base64(pubkey)
var trimmed = text.Trim();
if (trimmed.Contains('+', StringComparison.Ordinal) && trimmed.Count(static c => c == '+') >= 2)
{
var last = trimmed.Split('+')[^1];
try
{
return Convert.FromBase64String(last);
}
catch
{
// fall through to raw bytes
}
}
return bytes;
}
private static async Task<string?> ResolveCheckpointAsync(string checkpointField, string receiptDirectory, CancellationToken ct)
{
var value = checkpointField.Trim();
// If the value looks like a path and exists, load it.
var candidates = new List<string>();
if (value.IndexOfAny(['/', '\\']) >= 0 || value.EndsWith(".sig", StringComparison.OrdinalIgnoreCase))
{
candidates.Add(Path.IsPathRooted(value) ? value : Path.Combine(receiptDirectory, value));
}
candidates.Add(Path.Combine(receiptDirectory, "checkpoint.sig"));
candidates.Add(Path.Combine(receiptDirectory, "tlog", "checkpoint.sig"));
candidates.Add(Path.Combine(receiptDirectory, "evidence", "tlog", "checkpoint.sig"));
foreach (var candidate in candidates.Distinct(StringComparer.Ordinal))
{
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate, ct).ConfigureAwait(false);
}
}
// Otherwise treat as inline checkpoint content.
return value.Length > 0 ? checkpointField : null;
}
private static bool VerifyCheckpointSignature(ReadOnlySpan<byte> bodyUtf8, IReadOnlyList<byte[]> signatures, byte[] publicKey)
{
// Try ECDSA first (SPKI)
if (TryVerifyEcdsaCheckpoint(bodyUtf8, signatures, publicKey))
{
return true;
}
// Ed25519 fallback (raw 32-byte key or SPKI parsed via BouncyCastle)
if (TryVerifyEd25519Checkpoint(bodyUtf8, signatures, publicKey))
{
return true;
}
return false;
}
private static bool TryVerifyEcdsaCheckpoint(ReadOnlySpan<byte> bodyUtf8, IReadOnlyList<byte[]> signatures, byte[] publicKey)
{
try
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _);
foreach (var sig in signatures)
{
if (ecdsa.VerifyData(bodyUtf8, sig, HashAlgorithmName.SHA256))
{
return true;
}
// Some encoders store a raw (r||s) 64-byte signature.
if (sig.Length == 64 && ecdsa.VerifyData(bodyUtf8, sig, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation))
{
return true;
}
}
}
catch
{
// Not an ECDSA key or signature format mismatch.
}
return false;
}
private static bool TryVerifyEd25519Checkpoint(ReadOnlySpan<byte> bodyUtf8, IReadOnlyList<byte[]> signatures, byte[] publicKey)
{
try
{
Ed25519PublicKeyParameters key;
if (publicKey.Length == 32)
{
key = new Ed25519PublicKeyParameters(publicKey, 0);
}
else
{
var parsed = PublicKeyFactory.CreateKey(publicKey);
if (parsed is not Ed25519PublicKeyParameters edKey)
{
return false;
}
key = edKey;
}
foreach (var sig in signatures)
{
var verifier = new Ed25519Signer();
verifier.Init(false, key);
var buffer = bodyUtf8.ToArray();
verifier.BlockUpdate(buffer, 0, buffer.Length);
if (verifier.VerifySignature(sig))
{
return true;
}
}
}
catch
{
return false;
}
return false;
}
private static byte[]? TryParseHashBytes(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed["sha256:".Length..];
}
// Hex (most common)
if (trimmed.Length % 2 == 0 && trimmed.All(static c => (c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F')))
{
try
{
return Convert.FromHexString(trimmed);
}
catch
{
return null;
}
}
// Base64
try
{
return Convert.FromBase64String(trimmed);
}
catch
{
return null;
}
}
private sealed record RekorReceiptDocument(
[property: JsonPropertyName("uuid")] string Uuid,
[property: JsonPropertyName("logIndex")] long LogIndex,
[property: JsonPropertyName("rootHash")] string RootHash,
[property: JsonPropertyName("hashes")] IReadOnlyList<string> Hashes,
[property: JsonPropertyName("checkpoint")] string Checkpoint);
private sealed class SigstoreCheckpoint
{
private SigstoreCheckpoint(
string origin,
long treeSize,
string rootHashBase64,
string? timestamp,
IReadOnlyList<byte[]> signatures,
byte[] bodyCanonicalUtf8)
{
Origin = origin;
TreeSize = treeSize;
RootHashBase64 = rootHashBase64;
Timestamp = timestamp;
Signatures = signatures;
BodyCanonicalUtf8 = bodyCanonicalUtf8;
}
public string Origin { get; }
public long TreeSize { get; }
public string RootHashBase64 { get; }
public string? Timestamp { get; }
public IReadOnlyList<byte[]> Signatures { get; }
public byte[] BodyCanonicalUtf8 { get; }
public static SigstoreCheckpoint? TryParse(string checkpointContent)
{
if (string.IsNullOrWhiteSpace(checkpointContent))
{
return null;
}
var lines = checkpointContent
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Split('\n')
.Select(static line => line.TrimEnd())
.ToList();
// Extract signatures first (note format: "— origin base64sig", or "sig <base64>").
var signatures = new List<byte[]>();
foreach (var line in lines)
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
{
continue;
}
if (trimmed.StartsWith("—", StringComparison.Ordinal) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
{
var token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
if (!string.IsNullOrWhiteSpace(token) && TryDecodeBase64(token, out var sigBytes))
{
signatures.Add(sigBytes);
}
continue;
}
if (trimmed.StartsWith("sig ", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("signature ", StringComparison.OrdinalIgnoreCase))
{
var token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
if (!string.IsNullOrWhiteSpace(token) && TryDecodeBase64(token, out var sigBytes))
{
signatures.Add(sigBytes);
}
}
}
// Body: first non-empty 3 lines (origin, size, root), optional 4th timestamp (digits).
var bodyLines = lines
.Select(static l => l.Trim())
.Where(static l => l.Length > 0)
.Where(static l => !LooksLikeSignatureLine(l))
.ToList();
if (bodyLines.Count < 3)
{
return null;
}
var origin = bodyLines[0];
if (!long.TryParse(bodyLines[1], out var treeSize) || treeSize <= 0)
{
return null;
}
var rootBase64 = bodyLines[2];
// Validate base64 now; decode later for error messages.
if (!TryDecodeBase64(rootBase64, out _))
{
return null;
}
string? timestamp = null;
if (bodyLines.Count >= 4 && bodyLines[3].All(static c => c >= '0' && c <= '9'))
{
timestamp = bodyLines[3];
}
var canonical = new StringBuilder();
canonical.Append(origin);
canonical.Append('\n');
canonical.Append(treeSize.ToString(System.Globalization.CultureInfo.InvariantCulture));
canonical.Append('\n');
canonical.Append(rootBase64);
canonical.Append('\n');
if (!string.IsNullOrWhiteSpace(timestamp))
{
canonical.Append(timestamp);
canonical.Append('\n');
}
return new SigstoreCheckpoint(
origin,
treeSize,
rootBase64,
timestamp,
signatures,
Encoding.UTF8.GetBytes(canonical.ToString()));
}
private static bool LooksLikeSignatureLine(string trimmedLine)
{
if (trimmedLine.StartsWith("—", StringComparison.Ordinal))
{
return true;
}
if (trimmedLine.StartsWith("--", StringComparison.Ordinal))
{
return true;
}
if (trimmedLine.StartsWith("sig ", StringComparison.OrdinalIgnoreCase) ||
trimmedLine.StartsWith("signature ", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
private static bool TryDecodeBase64(string token, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(token);
return true;
}
catch
{
bytes = Array.Empty<byte>();
return false;
}
}
}
private static class Rfc6962Merkle
{
private const byte LeafPrefix = 0x00;
private const byte NodePrefix = 0x01;
public static byte[] HashLeaf(ReadOnlySpan<byte> leafData)
{
var buffer = new byte[1 + leafData.Length];
buffer[0] = LeafPrefix;
leafData.CopyTo(buffer.AsSpan(1));
return SHA256.HashData(buffer);
}
public static byte[] HashInterior(ReadOnlySpan<byte> left, ReadOnlySpan<byte> right)
{
var buffer = new byte[1 + left.Length + right.Length];
buffer[0] = NodePrefix;
left.CopyTo(buffer.AsSpan(1));
right.CopyTo(buffer.AsSpan(1 + left.Length));
return SHA256.HashData(buffer);
}
public static byte[]? ComputeRootFromPath(
byte[] leafHash,
long leafIndex,
long treeSize,
IReadOnlyList<byte[]> proofHashes)
{
if (leafIndex < 0 || treeSize <= 0 || leafIndex >= treeSize)
{
return null;
}
if (proofHashes.Count == 0)
{
return treeSize == 1 ? leafHash : null;
}
var currentHash = leafHash;
var proofIndex = 0;
var index = leafIndex;
var size = treeSize;
while (size > 1)
{
if (proofIndex >= proofHashes.Count)
{
return null;
}
var sibling = proofHashes[proofIndex++];
if (index % 2 == 0)
{
if (index + 1 < size)
{
currentHash = HashInterior(currentHash, sibling);
}
}
else
{
currentHash = HashInterior(sibling, currentHash);
}
index /= 2;
size = (size + 1) / 2;
}
return currentHash;
}
}
}
public sealed record RekorOfflineReceiptVerificationResult
{
public required bool Verified { get; init; }
public string? FailureReason { get; init; }
public string? RekorUuid { get; init; }
public long? LogIndex { get; init; }
public string? ComputedRootHash { get; init; }
public string? ExpectedRootHash { get; init; }
public long? TreeSize { get; init; }
public bool CheckpointSignatureVerified { get; init; }
public static RekorOfflineReceiptVerificationResult Success(
string rekorUuid,
long logIndex,
string computedRootHash,
string expectedRootHash,
long treeSize,
bool checkpointSignatureVerified) => new()
{
Verified = true,
RekorUuid = rekorUuid,
LogIndex = logIndex,
ComputedRootHash = computedRootHash,
ExpectedRootHash = expectedRootHash,
TreeSize = treeSize,
CheckpointSignatureVerified = checkpointSignatureVerified
};
public static RekorOfflineReceiptVerificationResult Failure(
string reason,
string? computedRootHash = null,
string? expectedRootHash = null,
long? treeSize = null,
bool checkpointSignatureVerified = false) => new()
{
Verified = false,
FailureReason = reason,
ComputedRootHash = computedRootHash,
ExpectedRootHash = expectedRootHash,
TreeSize = treeSize,
CheckpointSignatureVerified = checkpointSignatureVerified
};
}

View File

@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.AirGap.Importer.Reconciliation;
namespace StellaOps.AirGap.Importer.Tests.Reconciliation;
public sealed class EvidenceReconcilerDsseSigningTests
{
[Fact]
public async Task ReconcileAsync_WhenSignOutputEnabled_WritesDeterministicDsseEnvelopeWithValidSignature()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var pem = ecdsa.ExportPkcs8PrivateKeyPem();
var root = Path.Combine(Path.GetTempPath(), "stellaops-airgap-importer-tests", Guid.NewGuid().ToString("n"));
var inputDir = Path.Combine(root, "input");
var outputDir = Path.Combine(root, "output");
Directory.CreateDirectory(inputDir);
Directory.CreateDirectory(outputDir);
var keyPath = Path.Combine(root, "evidence-signing-key.pem");
await File.WriteAllTextAsync(keyPath, pem, Encoding.UTF8);
var reconciler = new EvidenceReconciler();
var options = new ReconciliationOptions
{
GeneratedAtUtc = DateTimeOffset.UnixEpoch,
SignOutput = true,
SigningPrivateKeyPemPath = keyPath
};
var graph1 = await reconciler.ReconcileAsync(inputDir, outputDir, options);
var dssePath = Path.Combine(outputDir, "evidence-graph.dsse.json");
var firstBytes = await File.ReadAllBytesAsync(dssePath);
var graph2 = await reconciler.ReconcileAsync(inputDir, outputDir, options);
var secondBytes = await File.ReadAllBytesAsync(dssePath);
Assert.Equal(firstBytes, secondBytes);
using var json = JsonDocument.Parse(firstBytes);
var rootElement = json.RootElement;
Assert.Equal("application/vnd.stellaops.evidence-graph+json", rootElement.GetProperty("payloadType").GetString());
var payloadBytes = Convert.FromBase64String(rootElement.GetProperty("payload").GetString()!);
var signatureElement = rootElement.GetProperty("signatures")[0];
var signatureBytes = Convert.FromBase64String(signatureElement.GetProperty("sig").GetString()!);
var expectedPayload = new EvidenceGraphSerializer().Serialize(graph1, pretty: false);
Assert.Equal(expectedPayload, Encoding.UTF8.GetString(payloadBytes));
var pae = EncodeDssePreAuth("application/vnd.stellaops.evidence-graph+json", payloadBytes);
Assert.True(ecdsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256));
var keyId = signatureElement.GetProperty("keyid").GetString();
Assert.False(string.IsNullOrWhiteSpace(keyId));
Assert.Equal(new EvidenceGraphSerializer().Serialize(graph1, pretty: false), new EvidenceGraphSerializer().Serialize(graph2, pretty: false));
}
private static byte[] EncodeDssePreAuth(string payloadType, ReadOnlySpan<byte> payload)
{
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"DSSEv1 {payloadTypeByteCount} {payloadType} {payload.Length} ";
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
return buffer;
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\StellaOps.AirGap.Importer\\StellaOps.AirGap.Importer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
# StellaOps.Attestor.Persistence — Local Agent Charter
## Scope
- This charter applies to `src/Attestor/__Libraries/StellaOps.Attestor.Persistence/**`.
## Primary roles
- Backend engineer (C# / .NET 10, EF Core, Npgsql).
- QA automation engineer (xUnit) for persistence + matcher logic.
## Required reading (treat as read before edits)
- `docs/modules/attestor/architecture.md`
- `docs/db/SPECIFICATION.md`
- `docs/db/MIGRATION_STRATEGY.md`
- PostgreSQL 16 docs (arrays, indexes, JSONB, query plans).
## Working agreements
- Determinism is mandatory where hashes/IDs are produced; all timestamps are UTC.
- Offline-friendly defaults: no network calls from library code paths.
- Migrations must be idempotent and safe to re-run.
- Prefer small, composable services with explicit interfaces (`I*`).
## Testing expectations
- Unit/integration tests live in `src/Attestor/__Tests/StellaOps.Attestor.Persistence.Tests`.
- Perf dataset and query harness lives under `src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Perf` and must be deterministic (fixed data, fixed sizes, documented parameters).

View File

@@ -5,6 +5,9 @@
-- Create schema -- Create schema
CREATE SCHEMA IF NOT EXISTS proofchain; CREATE SCHEMA IF NOT EXISTS proofchain;
-- Required for gen_random_uuid() defaults
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Create verification_result enum type -- Create verification_result enum type
DO $$ DO $$
BEGIN BEGIN

View File

@@ -0,0 +1,18 @@
# ProofChain DB perf harness
This folder provides a deterministic, production-like dataset and a small harness to validate index/query performance for the ProofChain schema (`proofchain.*`).
## Files
- `seed.sql` deterministic dataset generator (uses SQL functions + `generate_series`).
- `queries.sql` representative queries with `EXPLAIN (ANALYZE, BUFFERS)`.
- `run-perf.ps1` starts a local PostgreSQL 16 container, applies migrations, seeds data, runs queries, and captures output.
## Run
From repo root:
```powershell
pwsh -File src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Perf/run-perf.ps1
```
Output is written to `docs/db/reports/proofchain-schema-perf-2025-12-17.md`.

View File

@@ -0,0 +1,57 @@
-- Representative query set for ProofChain schema perf validation.
-- Run after applying migrations + seeding (`seed.sql`).
\timing on
-- Row counts
SELECT
(SELECT count(*) FROM proofchain.trust_anchors) AS trust_anchors,
(SELECT count(*) FROM proofchain.sbom_entries) AS sbom_entries,
(SELECT count(*) FROM proofchain.dsse_envelopes) AS dsse_envelopes,
(SELECT count(*) FROM proofchain.spines) AS spines,
(SELECT count(*) FROM proofchain.rekor_entries) AS rekor_entries;
-- 1) SBOM entry lookup via unique constraint (bom_digest, purl, version)
EXPLAIN (ANALYZE, BUFFERS)
SELECT entry_id, bom_digest, purl, version
FROM proofchain.sbom_entries
WHERE bom_digest = proofchain.hex64('bom:1')
AND purl = format('pkg:npm/vendor-%02s/pkg-%05s', 1, 1)
AND version = '1.0.1';
-- 2) Fetch all entries for a given SBOM digest (index on bom_digest)
EXPLAIN (ANALYZE, BUFFERS)
SELECT entry_id, purl, version
FROM proofchain.sbom_entries
WHERE bom_digest = proofchain.hex64('bom:1')
ORDER BY purl
LIMIT 100;
-- 3) Envelopes for entry + predicate (compound index)
EXPLAIN (ANALYZE, BUFFERS)
SELECT env_id, predicate_type, signer_keyid, body_hash
FROM proofchain.dsse_envelopes
WHERE entry_id = proofchain.uuid_from_text('entry:1')
AND predicate_type = 'evidence.stella/v1';
-- 4) Spine lookup via bundle_id (unique index)
EXPLAIN (ANALYZE, BUFFERS)
SELECT entry_id, bundle_id, policy_version
FROM proofchain.spines
WHERE bundle_id = proofchain.hex64('bundle:1');
-- 5) Rekor lookup by log index (index)
EXPLAIN (ANALYZE, BUFFERS)
SELECT dsse_sha256, uuid, integrated_time
FROM proofchain.rekor_entries
WHERE log_index = 10;
-- 6) Join: entries -> envelopes by bom_digest
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.entry_id, d.predicate_type, d.body_hash
FROM proofchain.sbom_entries e
JOIN proofchain.dsse_envelopes d ON d.entry_id = e.entry_id
WHERE e.bom_digest = proofchain.hex64('bom:1')
AND d.predicate_type = 'evidence.stella/v1'
ORDER BY e.purl
LIMIT 100;

View File

@@ -0,0 +1,104 @@
param(
[string]$PostgresImage = "postgres:16",
[string]$ContainerName = "stellaops-proofchain-perf",
[int]$Port = 54329,
[string]$Database = "proofchain_perf",
[string]$User = "postgres",
[string]$Password = "postgres"
)
$ErrorActionPreference = "Stop"
function Resolve-RepoRoot {
$here = Split-Path -Parent $PSCommandPath
return (Resolve-Path (Join-Path $here "../../../../..")).Path
}
$repoRoot = Resolve-RepoRoot
$perfDir = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Perf"
$migrationFile = Join-Path $repoRoot "src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/20251214000001_AddProofChainSchema.sql"
$seedFile = Join-Path $perfDir "seed.sql"
$queriesFile = Join-Path $perfDir "queries.sql"
$reportFile = Join-Path $repoRoot "docs/db/reports/proofchain-schema-perf-2025-12-17.md"
Write-Host "Using repo root: $repoRoot"
Write-Host "Starting PostgreSQL container '$ContainerName' on localhost:$Port..."
try {
docker rm -f $ContainerName *> $null 2>&1
} catch {}
$null = docker run --rm -d --name $ContainerName `
-e POSTGRES_PASSWORD=$Password `
-e POSTGRES_DB=$Database `
-p ${Port}:5432 `
$PostgresImage
try {
$ready = $false
for ($i = 0; $i -lt 60; $i++) {
docker exec $ContainerName pg_isready -U $User -d $Database *> $null 2>&1
if ($LASTEXITCODE -eq 0) {
$ready = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $ready) {
throw "PostgreSQL did not become ready within 60 seconds."
}
Write-Host "Applying migrations..."
$migrationSql = Get-Content -Raw -Encoding UTF8 $migrationFile
$migrationSql | docker exec -i $ContainerName psql -v ON_ERROR_STOP=1 -U $User -d $Database | Out-Host
Write-Host "Seeding deterministic dataset..."
$seedSql = Get-Content -Raw -Encoding UTF8 $seedFile
$seedSql | docker exec -i $ContainerName psql -v ON_ERROR_STOP=1 -U $User -d $Database | Out-Host
Write-Host "Running query suite..."
$queriesSql = Get-Content -Raw -Encoding UTF8 $queriesFile
$queryOutput = $queriesSql | docker exec -i $ContainerName psql -v ON_ERROR_STOP=1 -U $User -d $Database
$queryOutputText = ($queryOutput -join "`n").TrimEnd()
$headerLines = @(
'# ProofChain schema performance report (2025-12-17)',
'',
'## Environment',
('- Postgres image: `{0}`' -f $PostgresImage),
('- DB: `{0}`' -f $Database),
('- Port: `{0}`' -f $Port),
'- Host: `localhost`',
'',
'## Dataset',
'- Source: `src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Perf/seed.sql`',
'- Rows:',
' - `trust_anchors`: 50',
' - `sbom_entries`: 20000',
' - `dsse_envelopes`: 60000',
' - `spines`: 20000',
' - `rekor_entries`: 2000',
'',
'## Query Output',
'',
'```text',
$queryOutputText,
'```',
''
)
$header = ($headerLines -join "`n")
$dir = Split-Path -Parent $reportFile
if (!(Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Set-Content -Path $reportFile -Value $header -Encoding UTF8
Write-Host "Wrote report: $reportFile"
}
finally {
Write-Host "Stopping container..."
docker rm -f $ContainerName *> $null 2>&1
}

View File

@@ -0,0 +1,166 @@
-- Deterministic ProofChain dataset generator (offline-friendly).
-- Designed for index/query perf validation (SPRINT_0501_0006_0001 · PROOF-DB-0011).
-- Helper: deterministic UUID from text (no extensions required).
CREATE OR REPLACE FUNCTION proofchain.uuid_from_text(input text) RETURNS uuid
LANGUAGE SQL
IMMUTABLE
STRICT
AS $$
SELECT (
substring(md5(input), 1, 8) || '-' ||
substring(md5(input), 9, 4) || '-' ||
substring(md5(input), 13, 4) || '-' ||
substring(md5(input), 17, 4) || '-' ||
substring(md5(input), 21, 12)
)::uuid;
$$;
-- Helper: deterministic 64-hex string from text.
CREATE OR REPLACE FUNCTION proofchain.hex64(input text) RETURNS text
LANGUAGE SQL
IMMUTABLE
STRICT
AS $$
SELECT md5(input) || md5(input || ':2');
$$;
-- Parameters
-- Anchors: 50
-- SBOM entries: 20_000 (200 SBOM digests * 100 entries each)
-- Envelopes: 60_000 (3 per entry)
-- Spines: 20_000 (1 per entry)
-- Rekor entries: 2_000 (every 10th entry)
-- Trust anchors
INSERT INTO proofchain.trust_anchors(
anchor_id,
purl_pattern,
allowed_keyids,
allowed_predicate_types,
policy_ref,
policy_version,
revoked_keys,
is_active,
created_at,
updated_at
)
SELECT
proofchain.uuid_from_text('anchor:' || i),
format('pkg:npm/vendor-%02s/*', i),
ARRAY[format('key-%02s', i)]::text[],
ARRAY[
'evidence.stella/v1',
'reasoning.stella/v1',
'cdx-vex.stella/v1',
'proofspine.stella/v1',
'verdict.stella/v1',
'https://stella-ops.org/predicates/sbom-linkage/v1'
]::text[],
format('policy-%02s', i),
'v2025.12',
ARRAY[]::text[],
TRUE,
TIMESTAMPTZ '2025-12-17T00:00:00Z',
TIMESTAMPTZ '2025-12-17T00:00:00Z'
FROM generate_series(1, 50) i
ON CONFLICT (anchor_id) DO NOTHING;
-- SBOM entries
INSERT INTO proofchain.sbom_entries(
entry_id,
bom_digest,
purl,
version,
artifact_digest,
trust_anchor_id,
created_at
)
SELECT
proofchain.uuid_from_text('entry:' || i),
proofchain.hex64('bom:' || (((i - 1) / 100) + 1)),
format('pkg:npm/vendor-%02s/pkg-%05s', (((i - 1) % 50) + 1), i),
format('1.0.%s', (((i - 1) % 50) + 1)),
proofchain.hex64('artifact:' || i),
proofchain.uuid_from_text('anchor:' || (((i - 1) % 50) + 1)),
TIMESTAMPTZ '2025-12-17T00:00:00Z' + ((i - 1) || ' seconds')::interval
FROM generate_series(1, 20000) i
ON CONFLICT ON CONSTRAINT uq_sbom_entry DO NOTHING;
-- DSSE envelopes (3 per entry)
INSERT INTO proofchain.dsse_envelopes(
env_id,
entry_id,
predicate_type,
signer_keyid,
body_hash,
envelope_blob_ref,
signed_at,
created_at
)
SELECT
proofchain.uuid_from_text('env:' || i || ':' || p.predicate_type),
proofchain.uuid_from_text('entry:' || i),
p.predicate_type,
format('key-%02s', (((i - 1) % 50) + 1)),
proofchain.hex64('body:' || i || ':' || p.predicate_type),
format('oci://proofchain/blobs/%s', proofchain.hex64('body:' || i || ':' || p.predicate_type)),
TIMESTAMPTZ '2025-12-17T00:00:00Z' + ((i - 1) || ' seconds')::interval,
TIMESTAMPTZ '2025-12-17T00:00:00Z' + ((i - 1) || ' seconds')::interval
FROM generate_series(1, 20000) i
CROSS JOIN (
VALUES
('evidence.stella/v1'),
('reasoning.stella/v1'),
('cdx-vex.stella/v1')
) AS p(predicate_type)
ON CONFLICT ON CONSTRAINT uq_dsse_envelope DO NOTHING;
-- Spines (1 per entry)
INSERT INTO proofchain.spines(
entry_id,
bundle_id,
evidence_ids,
reasoning_id,
vex_id,
anchor_id,
policy_version,
created_at
)
SELECT
proofchain.uuid_from_text('entry:' || i),
proofchain.hex64('bundle:' || i),
ARRAY[
'sha256:' || proofchain.hex64('evidence:' || i || ':1'),
'sha256:' || proofchain.hex64('evidence:' || i || ':2'),
'sha256:' || proofchain.hex64('evidence:' || i || ':3')
]::text[],
proofchain.hex64('reasoning:' || i),
proofchain.hex64('vex:' || i),
proofchain.uuid_from_text('anchor:' || (((i - 1) % 50) + 1)),
'v2025.12',
TIMESTAMPTZ '2025-12-17T00:00:00Z' + ((i - 1) || ' seconds')::interval
FROM generate_series(1, 20000) i
ON CONFLICT ON CONSTRAINT uq_spine_bundle DO NOTHING;
-- Rekor entries (every 10th entry, points at the evidence envelope)
INSERT INTO proofchain.rekor_entries(
dsse_sha256,
log_index,
log_id,
uuid,
integrated_time,
inclusion_proof,
env_id
)
SELECT
proofchain.hex64('rekor:' || i),
i,
'test-log',
format('uuid-%s', i),
1734393600 + i,
'{"hashes":[],"treeSize":1,"rootHash":"00"}'::jsonb,
proofchain.uuid_from_text('env:' || i || ':evidence.stella/v1')
FROM generate_series(1, 20000, 10) i
ON CONFLICT (dsse_sha256) DO NOTHING;

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Persistence.Entities; using StellaOps.Attestor.Persistence.Entities;
using StellaOps.Attestor.Persistence.Repositories;
namespace StellaOps.Attestor.Persistence.Services; namespace StellaOps.Attestor.Persistence.Services;
@@ -75,7 +76,7 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
{ {
ArgumentException.ThrowIfNullOrEmpty(purl); ArgumentException.ThrowIfNullOrEmpty(purl);
var anchors = await _repository.GetActiveAnchorsAsync(cancellationToken); var anchors = await _repository.GetActiveTrustAnchorsAsync(cancellationToken);
TrustAnchorMatchResult? bestMatch = null; TrustAnchorMatchResult? bestMatch = null;
@@ -284,14 +285,3 @@ public sealed class TrustAnchorMatcher : ITrustAnchorMatcher
return true; return true;
} }
} }
/// <summary>
/// Repository interface extension for trust anchor queries.
/// </summary>
public interface IProofChainRepository
{
/// <summary>
/// Gets all active trust anchors.
/// </summary>
Task<IReadOnlyList<TrustAnchorEntity>> GetActiveAnchorsAsync(CancellationToken cancellationToken = default);
}

View File

@@ -20,4 +20,8 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Remove="Tests\\**\\*.cs" />
</ItemGroup>
</Project> </Project>

View File

@@ -84,10 +84,15 @@ public abstract record ContentAddressedId
} }
} }
public sealed record GenericContentAddressedId(string Algorithm, string Digest) : ContentAddressedId(Algorithm, Digest); public sealed record GenericContentAddressedId(string Algorithm, string Digest) : ContentAddressedId(Algorithm, Digest)
{
public override string ToString() => base.ToString();
}
public sealed record ArtifactId(string Digest) : ContentAddressedId("sha256", Digest) public sealed record ArtifactId(string Digest) : ContentAddressedId("sha256", Digest)
{ {
public override string ToString() => base.ToString();
public new static ArtifactId Parse(string value) => new(ParseSha256(value)); public new static ArtifactId Parse(string value) => new(ParseSha256(value));
public static bool TryParse(string value, out ArtifactId? id) => TryParseSha256(value, out id); public static bool TryParse(string value, out ArtifactId? id) => TryParseSha256(value, out id);
@@ -122,21 +127,29 @@ public sealed record ArtifactId(string Digest) : ContentAddressedId("sha256", Di
public sealed record EvidenceId(string Digest) : ContentAddressedId("sha256", Digest) public sealed record EvidenceId(string Digest) : ContentAddressedId("sha256", Digest)
{ {
public override string ToString() => base.ToString();
public new static EvidenceId Parse(string value) => new(Sha256IdParser.Parse(value, "EvidenceID")); public new static EvidenceId Parse(string value) => new(Sha256IdParser.Parse(value, "EvidenceID"));
} }
public sealed record ReasoningId(string Digest) : ContentAddressedId("sha256", Digest) public sealed record ReasoningId(string Digest) : ContentAddressedId("sha256", Digest)
{ {
public override string ToString() => base.ToString();
public new static ReasoningId Parse(string value) => new(Sha256IdParser.Parse(value, "ReasoningID")); public new static ReasoningId Parse(string value) => new(Sha256IdParser.Parse(value, "ReasoningID"));
} }
public sealed record VexVerdictId(string Digest) : ContentAddressedId("sha256", Digest) public sealed record VexVerdictId(string Digest) : ContentAddressedId("sha256", Digest)
{ {
public override string ToString() => base.ToString();
public new static VexVerdictId Parse(string value) => new(Sha256IdParser.Parse(value, "VEXVerdictID")); public new static VexVerdictId Parse(string value) => new(Sha256IdParser.Parse(value, "VEXVerdictID"));
} }
public sealed record ProofBundleId(string Digest) : ContentAddressedId("sha256", Digest) public sealed record ProofBundleId(string Digest) : ContentAddressedId("sha256", Digest)
{ {
public override string ToString() => base.ToString();
public new static ProofBundleId Parse(string value) => new(Sha256IdParser.Parse(value, "ProofBundleID")); public new static ProofBundleId Parse(string value) => new(Sha256IdParser.Parse(value, "ProofBundleID"));
} }

View File

@@ -0,0 +1,42 @@
using System;
using System.Globalization;
using System.Text;
namespace StellaOps.Attestor.ProofChain.Signing;
internal static class DssePreAuthenticationEncoding
{
public static byte[] Compute(string payloadType, ReadOnlySpan<byte> payload)
{
static byte[] Cat(params byte[][] parts)
{
var len = 0;
for (var i = 0; i < parts.Length; i++)
{
len += parts[i].Length;
}
var buf = new byte[len];
var offset = 0;
for (var i = 0; i < parts.Length; i++)
{
var part = parts[i];
Buffer.BlockCopy(part, 0, buf, offset, part.Length);
offset += part.Length;
}
return buf;
}
static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value);
var header = Utf8("DSSEv1");
var pt = Utf8(payloadType ?? string.Empty);
var lenPt = Utf8(pt.Length.ToString(CultureInfo.InvariantCulture));
var lenPayload = Utf8(payload.Length.ToString(CultureInfo.InvariantCulture));
var space = new byte[] { (byte)' ' };
return Cat(header, space, lenPt, space, pt, space, lenPayload, space, payload.ToArray());
}
}

View File

@@ -0,0 +1,20 @@
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestor.ProofChain.Signing;
/// <summary>
/// Provides key material for signing and verifying proof chain DSSE envelopes.
/// </summary>
public interface IProofChainKeyStore
{
/// <summary>
/// Resolve the signing key for a given key profile.
/// </summary>
bool TryGetSigningKey(SigningKeyProfile profile, out EnvelopeKey key);
/// <summary>
/// Resolve a verification key by key identifier.
/// </summary>
bool TryGetVerificationKey(string keyId, out EnvelopeKey key);
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Text.Json.Serialization;
using StellaOps.Attestor.ProofChain.Statements; using StellaOps.Attestor.ProofChain.Statements;
namespace StellaOps.Attestor.ProofChain.Signing; namespace StellaOps.Attestor.ProofChain.Signing;
@@ -55,16 +56,19 @@ public sealed record DsseEnvelope
/// <summary> /// <summary>
/// The payload type (always "application/vnd.in-toto+json"). /// The payload type (always "application/vnd.in-toto+json").
/// </summary> /// </summary>
[JsonPropertyName("payloadType")]
public required string PayloadType { get; init; } public required string PayloadType { get; init; }
/// <summary> /// <summary>
/// Base64-encoded payload (the statement JSON). /// Base64-encoded payload (the statement JSON).
/// </summary> /// </summary>
[JsonPropertyName("payload")]
public required string Payload { get; init; } public required string Payload { get; init; }
/// <summary> /// <summary>
/// Signatures over the payload. /// Signatures over the payload.
/// </summary> /// </summary>
[JsonPropertyName("signatures")]
public required IReadOnlyList<DsseSignature> Signatures { get; init; } public required IReadOnlyList<DsseSignature> Signatures { get; init; }
} }
@@ -76,11 +80,13 @@ public sealed record DsseSignature
/// <summary> /// <summary>
/// The key ID that produced this signature. /// The key ID that produced this signature.
/// </summary> /// </summary>
[JsonPropertyName("keyid")]
public required string KeyId { get; init; } public required string KeyId { get; init; }
/// <summary> /// <summary>
/// Base64-encoded signature. /// Base64-encoded signature.
/// </summary> /// </summary>
[JsonPropertyName("sig")]
public required string Sig { get; init; } public required string Sig { get; init; }
} }

View File

@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.ProofChain.Json;
using StellaOps.Attestor.ProofChain.Statements;
namespace StellaOps.Attestor.ProofChain.Signing;
/// <summary>
/// Default implementation for creating and verifying DSSE envelopes for proof chain statements.
/// </summary>
public sealed class ProofChainSigner : IProofChainSigner
{
public const string InTotoPayloadType = "application/vnd.in-toto+json";
private static readonly JsonSerializerOptions StatementSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = null,
WriteIndented = false
};
private readonly IProofChainKeyStore _keyStore;
private readonly IJsonCanonicalizer _canonicalizer;
private readonly EnvelopeSignatureService _signatureService;
public ProofChainSigner(
IProofChainKeyStore keyStore,
IJsonCanonicalizer canonicalizer,
EnvelopeSignatureService? signatureService = null)
{
_keyStore = keyStore ?? throw new ArgumentNullException(nameof(keyStore));
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
_signatureService = signatureService ?? new EnvelopeSignatureService();
}
public Task<DsseEnvelope> SignStatementAsync<T>(
T statement,
SigningKeyProfile keyProfile,
CancellationToken ct = default) where T : InTotoStatement
{
ArgumentNullException.ThrowIfNull(statement);
ct.ThrowIfCancellationRequested();
if (!_keyStore.TryGetSigningKey(keyProfile, out var key))
{
throw new InvalidOperationException($"No signing key configured for profile '{keyProfile}'.");
}
var statementJson = JsonSerializer.SerializeToUtf8Bytes(statement, statement.GetType(), StatementSerializerOptions);
var canonicalPayload = _canonicalizer.Canonicalize(statementJson);
var pae = DssePreAuthenticationEncoding.Compute(InTotoPayloadType, canonicalPayload);
var signatureResult = _signatureService.Sign(pae, key, ct);
if (!signatureResult.IsSuccess)
{
throw new InvalidOperationException($"DSSE signing failed: {signatureResult.Error.Code} {signatureResult.Error.Message}");
}
var signature = signatureResult.Value;
return Task.FromResult(new DsseEnvelope
{
PayloadType = InTotoPayloadType,
Payload = Convert.ToBase64String(canonicalPayload),
Signatures =
[
new DsseSignature
{
KeyId = signature.KeyId,
Sig = Convert.ToBase64String(signature.Value.Span)
}
]
});
}
public Task<SignatureVerificationResult> VerifyEnvelopeAsync(
DsseEnvelope envelope,
IReadOnlyList<string> allowedKeyIds,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(allowedKeyIds);
ct.ThrowIfCancellationRequested();
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
{
return Task.FromResult(new SignatureVerificationResult
{
IsValid = false,
KeyId = string.Empty,
ErrorMessage = "Envelope contains no signatures."
});
}
if (string.IsNullOrWhiteSpace(envelope.Payload))
{
return Task.FromResult(new SignatureVerificationResult
{
IsValid = false,
KeyId = string.Empty,
ErrorMessage = "Envelope payload is missing."
});
}
byte[] payloadBytes;
try
{
payloadBytes = Convert.FromBase64String(envelope.Payload);
}
catch (FormatException ex)
{
return Task.FromResult(new SignatureVerificationResult
{
IsValid = false,
KeyId = string.Empty,
ErrorMessage = $"Envelope payload is not valid base64: {ex.Message}"
});
}
var pae = DssePreAuthenticationEncoding.Compute(envelope.PayloadType, payloadBytes);
var allowAnyKey = allowedKeyIds.Count == 0;
var allowedSet = allowAnyKey ? null : new HashSet<string>(allowedKeyIds, StringComparer.Ordinal);
string? lastError = null;
foreach (var signature in envelope.Signatures.OrderBy(static s => s.KeyId, StringComparer.Ordinal))
{
if (signature is null)
{
continue;
}
if (!allowAnyKey && !allowedSet!.Contains(signature.KeyId))
{
continue;
}
if (!_keyStore.TryGetVerificationKey(signature.KeyId, out var verificationKey))
{
lastError = $"No verification key available for keyid '{signature.KeyId}'.";
continue;
}
byte[] signatureBytes;
try
{
signatureBytes = Convert.FromBase64String(signature.Sig);
}
catch (FormatException ex)
{
lastError = $"Signature for keyid '{signature.KeyId}' is not valid base64: {ex.Message}";
continue;
}
var envelopeSignature = new EnvelopeSignature(signature.KeyId, verificationKey.AlgorithmId, signatureBytes);
var verificationResult = _signatureService.Verify(pae, envelopeSignature, verificationKey, ct);
if (verificationResult.IsSuccess)
{
return Task.FromResult(new SignatureVerificationResult
{
IsValid = true,
KeyId = signature.KeyId
});
}
lastError = verificationResult.Error.Message;
}
if (!allowAnyKey)
{
var hasAllowed = envelope.Signatures.Any(s => allowedSet!.Contains(s.KeyId));
if (!hasAllowed)
{
return Task.FromResult(new SignatureVerificationResult
{
IsValid = false,
KeyId = string.Empty,
ErrorMessage = "No signatures match the allowed key IDs."
});
}
}
return Task.FromResult(new SignatureVerificationResult
{
IsValid = false,
KeyId = string.Empty,
ErrorMessage = lastError ?? "No valid signature found."
});
}
}

View File

@@ -8,4 +8,12 @@
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -133,21 +133,26 @@ public sealed class VerificationPipeline : IVerificationPipeline
var pipelineDuration = _timeProvider.GetUtcNow() - pipelineStartTime; var pipelineDuration = _timeProvider.GetUtcNow() - pipelineStartTime;
// Generate receipt // Generate receipt
var anchorId = context.TrustAnchorId ?? request.TrustAnchorId ?? new TrustAnchorId(Guid.Empty);
var checks = stepResults.Select(step => new VerificationCheck
{
Check = step.StepName,
Status = step.Passed ? VerificationResult.Pass : VerificationResult.Fail,
KeyId = step.KeyId,
Expected = step.Expected,
Actual = step.Actual,
LogIndex = step.LogIndex,
Details = step.Passed ? step.Details : step.ErrorMessage
}).ToList();
var receipt = new VerificationReceipt var receipt = new VerificationReceipt
{ {
ReceiptId = GenerateReceiptId(), ProofBundleId = request.ProofBundleId,
Result = overallPassed ? VerificationResult.Pass : VerificationResult.Fail,
VerifiedAt = pipelineStartTime, VerifiedAt = pipelineStartTime,
VerifierVersion = request.VerifierVersion, VerifierVersion = request.VerifierVersion,
ProofBundleId = request.ProofBundleId.Value, AnchorId = anchorId,
FailureReason = failureReason, Result = overallPassed ? VerificationResult.Pass : VerificationResult.Fail,
StepsSummary = stepResults.Select(s => new VerificationStepSummary Checks = checks
{
StepName = s.StepName,
Passed = s.Passed,
DurationMs = (int)s.Duration.TotalMilliseconds
}).ToList(),
TotalDurationMs = (int)pipelineDuration.TotalMilliseconds
}; };
_logger.LogInformation( _logger.LogInformation(
@@ -170,12 +175,6 @@ public sealed class VerificationPipeline : IVerificationPipeline
ErrorMessage = "Verification cancelled" ErrorMessage = "Verification cancelled"
}; };
private static string GenerateReceiptId()
{
var bytes = new byte[16];
RandomNumberGenerator.Fill(bytes);
return $"receipt:{Convert.ToHexString(bytes).ToLowerInvariant()}";
}
} }
/// <summary> /// <summary>
@@ -296,7 +295,7 @@ public sealed class IdRecomputationVerificationStep : IVerificationStep
var recomputedId = ComputeProofBundleId(bundle); var recomputedId = ComputeProofBundleId(bundle);
// Compare with claimed ID // Compare with claimed ID
var claimedId = context.ProofBundleId.Value; var claimedId = context.ProofBundleId.ToString();
if (!recomputedId.Equals(claimedId, StringComparison.OrdinalIgnoreCase)) if (!recomputedId.Equals(claimedId, StringComparison.OrdinalIgnoreCase))
{ {
return new VerificationStepResult return new VerificationStepResult
@@ -516,9 +515,19 @@ public sealed class TrustAnchorVerificationStep : IVerificationStep
} }
// Resolve trust anchor // Resolve trust anchor
var anchor = context.TrustAnchorId is not null TrustAnchorInfo? anchor;
? await _trustAnchorResolver.GetAnchorAsync(context.TrustAnchorId.Value, ct) if (context.TrustAnchorId is TrustAnchorId anchorId)
: await _trustAnchorResolver.FindAnchorForProofAsync(context.ProofBundleId, ct); {
anchor = await _trustAnchorResolver.GetAnchorAsync(anchorId.Value, ct);
}
else
{
anchor = await _trustAnchorResolver.FindAnchorForProofAsync(context.ProofBundleId, ct);
if (anchor is not null)
{
context.TrustAnchorId = new TrustAnchorId(anchor.AnchorId);
}
}
if (anchor is null) if (anchor is null)
{ {

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Persistence\StellaOps.Attestor.Persistence.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,184 +1,143 @@
using StellaOps.Attestor.Persistence.Entities; using FluentAssertions;
using StellaOps.Attestor.Persistence.Services;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Moq; using NSubstitute;
using Xunit; using StellaOps.Attestor.Persistence.Entities;
using StellaOps.Attestor.Persistence.Repositories;
using StellaOps.Attestor.Persistence.Services;
namespace StellaOps.Attestor.Persistence.Tests; namespace StellaOps.Attestor.Persistence.Tests;
/// <summary> /// <summary>
/// Integration tests for proof chain database operations. /// Tests for trust anchor glob matching and allowlists.
/// SPRINT_0501_0006_0001 - Task #10 /// Sprint: SPRINT_0501_0006_0001_proof_chain_database_schema
/// Task: PROOF-DB-0010
/// </summary> /// </summary>
public sealed class ProofChainRepositoryIntegrationTests public sealed class TrustAnchorMatcherTests
{ {
private readonly Mock<IProofChainRepository> _repositoryMock; private readonly IProofChainRepository _repository;
private readonly TrustAnchorMatcher _matcher; private readonly TrustAnchorMatcher _matcher;
public ProofChainRepositoryIntegrationTests() public TrustAnchorMatcherTests()
{ {
_repositoryMock = new Mock<IProofChainRepository>(); _repository = Substitute.For<IProofChainRepository>();
_matcher = new TrustAnchorMatcher( _matcher = new TrustAnchorMatcher(_repository, NullLogger<TrustAnchorMatcher>.Instance);
_repositoryMock.Object,
NullLogger<TrustAnchorMatcher>.Instance);
} }
[Fact] [Fact]
public async Task FindMatchAsync_ExactPattern_MatchesCorrectly() public async Task FindMatchAsync_ExactPattern_MatchesCorrectly()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/lodash@4.17.21", ["key-1"]); var anchor = CreateAnchor("pkg:npm/lodash@4.17.21", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21"); var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
// Assert result.Should().NotBeNull();
Assert.NotNull(result); result!.Anchor.AnchorId.Should().Be(anchor.AnchorId);
Assert.Equal(anchor.AnchorId, result.Anchor.AnchorId);
} }
[Fact] [Fact]
public async Task FindMatchAsync_WildcardPattern_MatchesPackages() public async Task FindMatchAsync_WildcardPattern_MatchesPackages()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]); var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21"); var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
// Assert result.Should().NotBeNull();
Assert.NotNull(result); result!.MatchedPattern.Should().Be("pkg:npm/*");
Assert.Equal("pkg:npm/*", result.MatchedPattern);
} }
[Fact] [Fact]
public async Task FindMatchAsync_DoubleWildcard_MatchesNestedPaths() public async Task FindMatchAsync_DoubleWildcard_MatchesNestedPaths()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/@scope/**", ["key-1"]); var anchor = CreateAnchor("pkg:npm/@scope/**", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/@scope/sub/package@1.0.0"); var result = await _matcher.FindMatchAsync("pkg:npm/@scope/sub/package@1.0.0");
// Assert result.Should().NotBeNull();
Assert.NotNull(result);
} }
[Fact] [Fact]
public async Task FindMatchAsync_MultipleMatches_ReturnsMoreSpecific() public async Task FindMatchAsync_MultipleMatches_ReturnsMoreSpecific()
{ {
// Arrange var genericAnchor = CreateAnchor("pkg:npm/*", ["key-generic"], policyRef: "generic");
var genericAnchor = CreateAnchor("pkg:npm/*", ["key-generic"], "generic"); var specificAnchor = CreateAnchor("pkg:npm/lodash@*", ["key-specific"], policyRef: "specific");
var specificAnchor = CreateAnchor("pkg:npm/lodash@*", ["key-specific"], "specific"); await SeedAnchors(genericAnchor, specificAnchor);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync([genericAnchor, specificAnchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21"); var result = await _matcher.FindMatchAsync("pkg:npm/lodash@4.17.21");
// Assert result.Should().NotBeNull();
Assert.NotNull(result); result!.Anchor.PolicyRef.Should().Be("specific");
Assert.Equal("specific", result.Anchor.PolicyRef);
} }
[Fact] [Fact]
public async Task FindMatchAsync_NoMatch_ReturnsNull() public async Task FindMatchAsync_NoMatch_ReturnsNull()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]); var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync("pkg:pypi/requests@2.28.0"); var result = await _matcher.FindMatchAsync("pkg:pypi/requests@2.28.0");
// Assert result.Should().BeNull();
Assert.Null(result);
} }
[Fact] [Fact]
public async Task IsKeyAllowedAsync_AllowedKey_ReturnsTrue() public async Task IsKeyAllowedAsync_AllowedKey_ReturnsTrue()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1", "key-2"]); var anchor = CreateAnchor("pkg:npm/*", ["key-1", "key-2"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1"); var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1");
// Assert allowed.Should().BeTrue();
Assert.True(allowed);
} }
[Fact] [Fact]
public async Task IsKeyAllowedAsync_DisallowedKey_ReturnsFalse() public async Task IsKeyAllowedAsync_DisallowedKey_ReturnsFalse()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]); var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-unknown"); var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-unknown");
// Assert allowed.Should().BeFalse();
Assert.False(allowed);
} }
[Fact] [Fact]
public async Task IsKeyAllowedAsync_RevokedKey_ReturnsFalse() public async Task IsKeyAllowedAsync_RevokedKey_ReturnsFalse()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"], revokedKeys: ["key-1"]); var anchor = CreateAnchor("pkg:npm/*", ["key-1"], revokedKeys: ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1"); var allowed = await _matcher.IsKeyAllowedAsync("pkg:npm/lodash@4.17.21", "key-1");
// Assert allowed.Should().BeFalse();
Assert.False(allowed); // Key is revoked even if in allowed list
} }
[Fact] [Fact]
public async Task IsPredicateAllowedAsync_NoRestrictions_AllowsAll() public async Task IsPredicateAllowedAsync_NoRestrictions_AllowsAll()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]); var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
anchor.AllowedPredicateTypes = null; anchor.AllowedPredicateTypes = null;
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var allowed = await _matcher.IsPredicateAllowedAsync( var allowed = await _matcher.IsPredicateAllowedAsync(
"pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.21",
"https://in-toto.io/attestation/vulns/v0.1"); "https://in-toto.io/attestation/vulns/v0.1");
// Assert allowed.Should().BeTrue();
Assert.True(allowed);
} }
[Fact] [Fact]
public async Task IsPredicateAllowedAsync_WithRestrictions_EnforcesAllowlist() public async Task IsPredicateAllowedAsync_WithRestrictions_EnforcesAllowlist()
{ {
// Arrange
var anchor = CreateAnchor("pkg:npm/*", ["key-1"]); var anchor = CreateAnchor("pkg:npm/*", ["key-1"]);
anchor.AllowedPredicateTypes = ["evidence.stella/v1", "sbom.stella/v1"]; anchor.AllowedPredicateTypes = ["evidence.stella/v1", "sbom.stella/v1"];
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act & Assert (await _matcher.IsPredicateAllowedAsync("pkg:npm/lodash@4.17.21", "evidence.stella/v1")).Should().BeTrue();
Assert.True(await _matcher.IsPredicateAllowedAsync( (await _matcher.IsPredicateAllowedAsync("pkg:npm/lodash@4.17.21", "random.predicate/v1")).Should().BeFalse();
"pkg:npm/lodash@4.17.21", "evidence.stella/v1"));
Assert.False(await _matcher.IsPredicateAllowedAsync(
"pkg:npm/lodash@4.17.21", "random.predicate/v1"));
} }
[Theory] [Theory]
@@ -190,19 +149,21 @@ public sealed class ProofChainRepositoryIntegrationTests
[InlineData("pkg:pypi/*", "pkg:npm/lodash@4.17.21", false)] [InlineData("pkg:pypi/*", "pkg:npm/lodash@4.17.21", false)]
[InlineData("pkg:npm/@scope/*", "pkg:npm/@scope/package@1.0.0", true)] [InlineData("pkg:npm/@scope/*", "pkg:npm/@scope/package@1.0.0", true)]
[InlineData("pkg:npm/@scope/*", "pkg:npm/@other/package@1.0.0", false)] [InlineData("pkg:npm/@scope/*", "pkg:npm/@other/package@1.0.0", false)]
public async Task FindMatchAsync_PatternVariations_MatchCorrectly( public async Task FindMatchAsync_PatternVariations_MatchCorrectly(string pattern, string purl, bool shouldMatch)
string pattern, string purl, bool shouldMatch)
{ {
// Arrange
var anchor = CreateAnchor(pattern, ["key-1"]); var anchor = CreateAnchor(pattern, ["key-1"]);
_repositoryMock.Setup(r => r.GetActiveAnchorsAsync(It.IsAny<CancellationToken>())) await SeedAnchors(anchor);
.ReturnsAsync([anchor]);
// Act
var result = await _matcher.FindMatchAsync(purl); var result = await _matcher.FindMatchAsync(purl);
// Assert (result != null).Should().Be(shouldMatch);
Assert.Equal(shouldMatch, result != null); }
private Task SeedAnchors(params TrustAnchorEntity[] anchors)
{
_repository.GetActiveTrustAnchorsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TrustAnchorEntity>>(anchors));
return Task.CompletedTask;
} }
private static TrustAnchorEntity CreateAnchor( private static TrustAnchorEntity CreateAnchor(
@@ -217,7 +178,8 @@ public sealed class ProofChainRepositoryIntegrationTests
PurlPattern = pattern, PurlPattern = pattern,
AllowedKeyIds = allowedKeys, AllowedKeyIds = allowedKeys,
PolicyRef = policyRef, PolicyRef = policyRef,
RevokedKeys = revokedKeys ?? [], RevokedKeys = revokedKeys ?? []
}; };
} }
} }

View File

@@ -1,631 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps Contributors
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using StellaOps.Attestor.ProofChain;
using StellaOps.Attestor.ProofChain.Statements;
using StellaOps.Attestor.ProofChain.Verification;
using Xunit;
namespace StellaOps.Attestor.ProofChain.Tests;
/// <summary>
/// Load tests for proof chain API endpoints and verification pipeline.
/// Sprint: SPRINT_0501_0005_0001_proof_chain_api_surface
/// Task: PROOF-API-0012
/// </summary>
public class ApiLoadTests
{
private readonly ILogger<VerificationPipeline> _logger = NullLogger<VerificationPipeline>.Instance;
#region Proof Spine Creation Load Tests
[Fact]
public async Task CreateProofSpine_ConcurrentRequests_MaintainsThroughput()
{
// Arrange: Create synthetic SBOM entries for load testing
const int concurrencyLevel = 50;
const int operationsPerClient = 20;
var totalOperations = concurrencyLevel * operationsPerClient;
var proofSpineBuilder = CreateTestProofSpineBuilder();
var latencies = new ConcurrentBag<long>();
var errors = new ConcurrentBag<Exception>();
var stopwatch = Stopwatch.StartNew();
// Act: Run concurrent proof spine creations
var tasks = Enumerable.Range(0, concurrencyLevel)
.Select(clientId => Task.Run(async () =>
{
for (var i = 0; i < operationsPerClient; i++)
{
try
{
var sw = Stopwatch.StartNew();
var entryId = GenerateSyntheticEntryId(clientId, i);
var spine = await proofSpineBuilder.BuildAsync(
entryId,
GenerateSyntheticEvidenceIds(3),
$"sha256:{GenerateHash("reasoning")}",
$"sha256:{GenerateHash("vex")}",
"v2.3.1",
CancellationToken.None);
sw.Stop();
latencies.Add(sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
errors.Add(ex);
}
}
}));
await Task.WhenAll(tasks);
stopwatch.Stop();
// Assert: Verify load test metrics
var successCount = latencies.Count;
var errorCount = errors.Count;
var throughput = successCount / stopwatch.Elapsed.TotalSeconds;
var avgLatency = latencies.Any() ? latencies.Average() : 0;
var p95Latency = CalculatePercentile(latencies, 95);
var p99Latency = CalculatePercentile(latencies, 99);
// Performance assertions
successCount.Should().Be(totalOperations, "all operations should complete successfully");
errorCount.Should().Be(0, "no errors should occur during load test");
throughput.Should().BeGreaterThan(100, "throughput should exceed 100 ops/sec");
avgLatency.Should().BeLessThan(50, "average latency should be under 50ms");
p99Latency.Should().BeLessThan(200, "p99 latency should be under 200ms");
}
[Fact]
public async Task VerificationPipeline_ConcurrentVerifications_MaintainsAccuracy()
{
// Arrange
const int concurrencyLevel = 30;
const int verificationsPerClient = 10;
var totalVerifications = concurrencyLevel * verificationsPerClient;
var mockDsseVerifier = CreateMockDsseVerifier();
var mockIdRecomputer = CreateMockIdRecomputer();
var mockRekorVerifier = CreateMockRekorVerifier();
var pipeline = new VerificationPipeline(
mockDsseVerifier,
mockIdRecomputer,
mockRekorVerifier,
_logger);
var results = new ConcurrentBag<VerificationResult>();
var latencies = new ConcurrentBag<long>();
// Act: Run concurrent verifications
var tasks = Enumerable.Range(0, concurrencyLevel)
.Select(clientId => Task.Run(async () =>
{
for (var i = 0; i < verificationsPerClient; i++)
{
var sw = Stopwatch.StartNew();
var proof = GenerateSyntheticProof(clientId, i);
var result = await pipeline.VerifyAsync(proof, CancellationToken.None);
sw.Stop();
latencies.Add(sw.ElapsedMilliseconds);
results.Add(result);
}
}));
await Task.WhenAll(tasks);
// Assert: All verifications should be deterministic
results.Count.Should().Be(totalVerifications);
results.All(r => r.IsValid).Should().BeTrue("all synthetic proofs should verify successfully");
var avgLatency = latencies.Average();
avgLatency.Should().BeLessThan(30, "verification should be fast");
}
#endregion
#region Deterministic Ordering Tests Under Load
[Fact]
public void ProofSpineOrdering_UnderConcurrency_RemainsDeterministic()
{
// Arrange: Same inputs should produce same outputs under concurrent access
const int iterations = 100;
var seed = 42;
var random = new Random(seed);
var evidenceIds = Enumerable.Range(0, 5)
.Select(i => $"sha256:{GenerateHash($"evidence{i}")}")
.ToArray();
var results = new ConcurrentBag<string>();
// Act: Compute proof spine hash concurrently multiple times
Parallel.For(0, iterations, _ =>
{
var sorted = evidenceIds.OrderBy(x => x).ToArray();
var combined = string.Join(":", sorted);
var hash = GenerateHash(combined);
results.Add(hash);
});
// Assert: All results should be identical (deterministic)
results.Distinct().Count().Should().Be(1, "concurrent computations should be deterministic");
}
[Fact]
public async Task MerkleTree_ConcurrentBuilding_ProducesSameRoot()
{
// Arrange
const int leafCount = 1000;
const int iterations = 20;
var leaves = Enumerable.Range(0, leafCount)
.Select(i => Encoding.UTF8.GetBytes($"leaf-{i:D5}"))
.ToList();
var roots = new ConcurrentBag<string>();
// Act: Build Merkle tree concurrently
await Parallel.ForEachAsync(Enumerable.Range(0, iterations), async (_, ct) =>
{
var builder = new MerkleTreeBuilder();
foreach (var leaf in leaves)
{
builder.AddLeaf(leaf);
}
var root = builder.ComputeRoot();
roots.Add(Convert.ToHexString(root));
});
// Assert: All roots should be identical
roots.Distinct().Count().Should().Be(1, "Merkle tree root should be deterministic");
}
#endregion
#region Throughput Benchmarks
[Theory]
[InlineData(10, 100)] // Light load
[InlineData(50, 50)] // Medium load
[InlineData(100, 20)] // Heavy load
public async Task ThroughputBenchmark_VariousLoadProfiles(int concurrency, int opsPerClient)
{
// Arrange
var totalOps = concurrency * opsPerClient;
var successCount = 0;
var stopwatch = Stopwatch.StartNew();
// Act: Simulate API calls
var tasks = Enumerable.Range(0, concurrency)
.Select(_ => Task.Run(() =>
{
for (var i = 0; i < opsPerClient; i++)
{
// Simulate proof creation work
var hash = GenerateHash($"proof-{Guid.NewGuid()}");
Interlocked.Increment(ref successCount);
}
}));
await Task.WhenAll(tasks);
stopwatch.Stop();
// Assert
var throughput = successCount / stopwatch.Elapsed.TotalSeconds;
successCount.Should().Be(totalOps);
throughput.Should().BeGreaterThan(1000, $"throughput at {concurrency} concurrency should exceed 1000 ops/sec");
}
[Fact]
public async Task LatencyDistribution_UnderLoad_MeetsSloBudgets()
{
// Arrange: Define SLO budgets
const double maxP50Ms = 10;
const double maxP90Ms = 25;
const double maxP99Ms = 100;
const int sampleSize = 1000;
var latencies = new ConcurrentBag<double>();
// Act: Collect latency samples
await Parallel.ForEachAsync(Enumerable.Range(0, sampleSize), async (i, ct) =>
{
var sw = Stopwatch.StartNew();
// Simulate verification work
var hash = GenerateHash($"sample-{i}");
await Task.Delay(1, ct); // Simulate I/O
sw.Stop();
latencies.Add(sw.Elapsed.TotalMilliseconds);
});
// Calculate percentiles
var sorted = latencies.OrderBy(x => x).ToList();
var p50 = CalculatePercentileFromSorted(sorted, 50);
var p90 = CalculatePercentileFromSorted(sorted, 90);
var p99 = CalculatePercentileFromSorted(sorted, 99);
// Assert: SLO compliance
p50.Should().BeLessThan(maxP50Ms, "p50 latency should meet SLO");
p90.Should().BeLessThan(maxP90Ms, "p90 latency should meet SLO");
p99.Should().BeLessThan(maxP99Ms, "p99 latency should meet SLO");
}
#endregion
#region Memory and Resource Tests
[Fact]
public void LargeProofBatch_DoesNotCauseMemorySpike()
{
// Arrange
const int batchSize = 10_000;
var initialMemory = GC.GetTotalMemory(true);
// Act: Create large batch of proofs
var proofs = new List<string>(batchSize);
for (var i = 0; i < batchSize; i++)
{
var proof = GenerateSyntheticProofJson(i);
proofs.Add(proof);
}
// Force GC and measure
var peakMemory = GC.GetTotalMemory(false);
proofs.Clear();
GC.Collect();
var finalMemory = GC.GetTotalMemory(true);
// Assert: Memory should not grow unbounded
var memoryGrowth = peakMemory - initialMemory;
var memoryRetained = finalMemory - initialMemory;
// Each proof is ~500 bytes, so 10k proofs ≈ 5MB is reasonable
memoryGrowth.Should().BeLessThan(50_000_000, "memory growth should be bounded (~50MB max for 10k proofs)");
memoryRetained.Should().BeLessThan(10_000_000, "memory should be released after clearing");
}
#endregion
#region Helper Methods
private static IProofSpineBuilder CreateTestProofSpineBuilder()
{
// Create a mock proof spine builder for load testing
var builder = Substitute.For<IProofSpineBuilder>();
builder.BuildAsync(
Arg.Any<string>(),
Arg.Any<string[]>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var entryId = callInfo.ArgAt<string>(0);
return Task.FromResult(new ProofSpine
{
EntryId = entryId,
SpineId = $"sha256:{GenerateHash(entryId)}",
PolicyVersion = callInfo.ArgAt<string>(4),
CreatedAt = DateTimeOffset.UtcNow
});
});
return builder;
}
private static IDsseVerifier CreateMockDsseVerifier()
{
var verifier = Substitute.For<IDsseVerifier>();
verifier.VerifyAsync(Arg.Any<DsseEnvelope>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new DsseVerificationResult { IsValid = true }));
return verifier;
}
private static IIdRecomputer CreateMockIdRecomputer()
{
var recomputer = Substitute.For<IIdRecomputer>();
recomputer.VerifyAsync(Arg.Any<ProofBundle>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new IdVerificationResult { IsValid = true }));
return recomputer;
}
private static IRekorVerifier CreateMockRekorVerifier()
{
var verifier = Substitute.For<IRekorVerifier>();
verifier.VerifyInclusionAsync(Arg.Any<RekorEntry>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new RekorVerificationResult { IsValid = true }));
return verifier;
}
private static string GenerateSyntheticEntryId(int clientId, int index)
{
var hash = GenerateHash($"entry-{clientId}-{index}");
return $"sha256:{hash}:pkg:npm/example@1.0.{index}";
}
private static string[] GenerateSyntheticEvidenceIds(int count)
{
return Enumerable.Range(0, count)
.Select(i => $"sha256:{GenerateHash($"evidence-{i}")}")
.ToArray();
}
private static ProofBundle GenerateSyntheticProof(int clientId, int index)
{
return new ProofBundle
{
EntryId = GenerateSyntheticEntryId(clientId, index),
Envelope = new DsseEnvelope
{
PayloadType = "application/vnd.stellaops.proof+json",
Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{{\"id\":\"{clientId}-{index}\"}}")),
Signatures = new[]
{
new DsseSignature
{
KeyId = "test-key",
Sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-signature"))
}
}
}
};
}
private static string GenerateSyntheticProofJson(int index)
{
return $@"{{
""entryId"": ""sha256:{GenerateHash($"entry-{index}")}:pkg:npm/example@1.0.{index}"",
""spineId"": ""sha256:{GenerateHash($"spine-{index}")}"",
""evidenceIds"": [""{GenerateHash($"ev1-{index}")}"", ""{GenerateHash($"ev2-{index}")}""],
""reasoningId"": ""sha256:{GenerateHash($"reason-{index}")}"",
""vexVerdictId"": ""sha256:{GenerateHash($"vex-{index}")}"",
""policyVersion"": ""v2.3.1"",
""createdAt"": ""{DateTimeOffset.UtcNow:O}""
}}";
}
private static string GenerateHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static double CalculatePercentile(ConcurrentBag<long> values, int percentile)
{
if (!values.Any()) return 0;
var sorted = values.OrderBy(x => x).ToList();
return CalculatePercentileFromSorted(sorted.Select(x => (double)x).ToList(), percentile);
}
private static double CalculatePercentileFromSorted<T>(List<T> sorted, int percentile) where T : IConvertible
{
if (sorted.Count == 0) return 0;
var index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1;
index = Math.Max(0, Math.Min(index, sorted.Count - 1));
return sorted[index].ToDouble(null);
}
#endregion
}
#region Supporting Types for Load Tests
/// <summary>
/// Interface for proof spine building (mock target for load tests).
/// </summary>
public interface IProofSpineBuilder
{
Task<ProofSpine> BuildAsync(
string entryId,
string[] evidenceIds,
string reasoningId,
string vexVerdictId,
string policyVersion,
CancellationToken cancellationToken);
}
/// <summary>
/// Represents a proof spine created for an SBOM entry.
/// </summary>
public class ProofSpine
{
public required string EntryId { get; init; }
public required string SpineId { get; init; }
public required string PolicyVersion { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Interface for DSSE envelope verification.
/// </summary>
public interface IDsseVerifier
{
Task<DsseVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken cancellationToken);
}
/// <summary>
/// DSSE verification result.
/// </summary>
public class DsseVerificationResult
{
public bool IsValid { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Interface for ID recomputation verification.
/// </summary>
public interface IIdRecomputer
{
Task<IdVerificationResult> VerifyAsync(ProofBundle bundle, CancellationToken cancellationToken);
}
/// <summary>
/// ID verification result.
/// </summary>
public class IdVerificationResult
{
public bool IsValid { get; init; }
public string? ExpectedId { get; init; }
public string? ActualId { get; init; }
}
/// <summary>
/// Interface for Rekor inclusion proof verification.
/// </summary>
public interface IRekorVerifier
{
Task<RekorVerificationResult> VerifyInclusionAsync(RekorEntry entry, CancellationToken cancellationToken);
}
/// <summary>
/// Rekor verification result.
/// </summary>
public class RekorVerificationResult
{
public bool IsValid { get; init; }
public long? LogIndex { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Represents a Rekor transparency log entry.
/// </summary>
public class RekorEntry
{
public long LogIndex { get; init; }
public string? LogId { get; init; }
public string? Body { get; init; }
public DateTimeOffset IntegratedTime { get; init; }
}
/// <summary>
/// DSSE envelope for proof bundles.
/// </summary>
public class DsseEnvelope
{
public required string PayloadType { get; init; }
public required string Payload { get; init; }
public required DsseSignature[] Signatures { get; init; }
}
/// <summary>
/// DSSE signature within an envelope.
/// </summary>
public class DsseSignature
{
public required string KeyId { get; init; }
public required string Sig { get; init; }
}
/// <summary>
/// Complete proof bundle for verification.
/// </summary>
public class ProofBundle
{
public required string EntryId { get; init; }
public required DsseEnvelope Envelope { get; init; }
public RekorEntry? RekorEntry { get; init; }
}
/// <summary>
/// Complete verification result from the pipeline.
/// </summary>
public class VerificationResult
{
public bool IsValid { get; init; }
public DsseVerificationResult? DsseResult { get; init; }
public IdVerificationResult? IdResult { get; init; }
public RekorVerificationResult? RekorResult { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Verification pipeline that runs all verification steps.
/// </summary>
public class VerificationPipeline
{
private readonly IDsseVerifier _dsseVerifier;
private readonly IIdRecomputer _idRecomputer;
private readonly IRekorVerifier _rekorVerifier;
private readonly ILogger<VerificationPipeline> _logger;
public VerificationPipeline(
IDsseVerifier dsseVerifier,
IIdRecomputer idRecomputer,
IRekorVerifier rekorVerifier,
ILogger<VerificationPipeline> logger)
{
_dsseVerifier = dsseVerifier;
_idRecomputer = idRecomputer;
_rekorVerifier = rekorVerifier;
_logger = logger;
}
public async Task<VerificationResult> VerifyAsync(ProofBundle bundle, CancellationToken cancellationToken)
{
// Step 1: DSSE signature verification
var dsseResult = await _dsseVerifier.VerifyAsync(bundle.Envelope, cancellationToken);
if (!dsseResult.IsValid)
{
return new VerificationResult
{
IsValid = false,
DsseResult = dsseResult,
Error = $"DSSE verification failed: {dsseResult.Error}"
};
}
// Step 2: ID recomputation
var idResult = await _idRecomputer.VerifyAsync(bundle, cancellationToken);
if (!idResult.IsValid)
{
return new VerificationResult
{
IsValid = false,
DsseResult = dsseResult,
IdResult = idResult,
Error = $"ID mismatch: expected {idResult.ExpectedId}, got {idResult.ActualId}"
};
}
// Step 3: Rekor inclusion (if entry present)
RekorVerificationResult? rekorResult = null;
if (bundle.RekorEntry != null)
{
rekorResult = await _rekorVerifier.VerifyInclusionAsync(bundle.RekorEntry, cancellationToken);
if (!rekorResult.IsValid)
{
return new VerificationResult
{
IsValid = false,
DsseResult = dsseResult,
IdResult = idResult,
RekorResult = rekorResult,
Error = $"Rekor verification failed: {rekorResult.Error}"
};
}
}
return new VerificationResult
{
IsValid = true,
DsseResult = dsseResult,
IdResult = idResult,
RekorResult = rekorResult
};
}
}
#endregion

View File

@@ -18,7 +18,7 @@ public class ContentAddressedIdGeneratorTests
public ContentAddressedIdGeneratorTests() public ContentAddressedIdGeneratorTests()
{ {
var canonicalizer = new JsonCanonicalizer(); var canonicalizer = new Rfc8785JsonCanonicalizer();
var merkleBuilder = new DeterministicMerkleTreeBuilder(); var merkleBuilder = new DeterministicMerkleTreeBuilder();
_generator = new ContentAddressedIdGenerator(canonicalizer, merkleBuilder); _generator = new ContentAddressedIdGenerator(canonicalizer, merkleBuilder);
} }
@@ -117,8 +117,8 @@ public class ContentAddressedIdGeneratorTests
[Fact] [Fact]
public void ComputeVexVerdictId_DifferentStatus_ProducesDifferentId() public void ComputeVexVerdictId_DifferentStatus_ProducesDifferentId()
{ {
var predicate1 = CreateTestVexPredicate() with { Status = VexStatus.Affected }; var predicate1 = CreateTestVexPredicate() with { Status = "affected" };
var predicate2 = CreateTestVexPredicate() with { Status = VexStatus.NotAffected }; var predicate2 = CreateTestVexPredicate() with { Status = "not_affected" };
var id1 = _generator.ComputeVexVerdictId(predicate1); var id1 = _generator.ComputeVexVerdictId(predicate1);
var id2 = _generator.ComputeVexVerdictId(predicate2); var id2 = _generator.ComputeVexVerdictId(predicate2);
@@ -152,8 +152,8 @@ public class ContentAddressedIdGeneratorTests
var vexVerdictId = CreateTestVexVerdictId(); var vexVerdictId = CreateTestVexVerdictId();
// Different order, should produce same result // Different order, should produce same result
var unsorted = new[] { CreateTestEvidenceId("z"), CreateTestEvidenceId("a") }; var unsorted = new[] { CreateTestEvidenceId("f"), CreateTestEvidenceId("a") };
var sorted = new[] { CreateTestEvidenceId("a"), CreateTestEvidenceId("z") }; var sorted = new[] { CreateTestEvidenceId("a"), CreateTestEvidenceId("f") };
var id1 = _generator.ComputeProofBundleId(sbomEntryId, unsorted, reasoningId, vexVerdictId); var id1 = _generator.ComputeProofBundleId(sbomEntryId, unsorted, reasoningId, vexVerdictId);
var id2 = _generator.ComputeProofBundleId(sbomEntryId, sorted, reasoningId, vexVerdictId); var id2 = _generator.ComputeProofBundleId(sbomEntryId, sorted, reasoningId, vexVerdictId);
@@ -272,9 +272,9 @@ public class ContentAddressedIdGeneratorTests
SbomEntryId = "sha256:sbom123:pkg:npm/lodash@4.17.21", SbomEntryId = "sha256:sbom123:pkg:npm/lodash@4.17.21",
EvidenceIds = ["sha256:evidence1", "sha256:evidence2"], EvidenceIds = ["sha256:evidence1", "sha256:evidence2"],
PolicyVersion = "v2024.12.16", PolicyVersion = "v2024.12.16",
Inputs = new ReasoningInputs Inputs = new Dictionary<string, object>
{ {
CurrentEvaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero) ["currentEvaluationTime"] = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero)
} }
}; };
@@ -282,12 +282,14 @@ public class ContentAddressedIdGeneratorTests
{ {
SbomEntryId = "sha256:sbom123:pkg:npm/lodash@4.17.21", SbomEntryId = "sha256:sbom123:pkg:npm/lodash@4.17.21",
VulnerabilityId = "CVE-2024-1234", VulnerabilityId = "CVE-2024-1234",
Status = VexStatus.NotAffected, Status = "not_affected",
Justification = "Vulnerable code is not in execution path" Justification = "vulnerable_code_not_present",
PolicyVersion = "v2024.12.16",
ReasoningId = "sha256:reasoning1"
}; };
private static SbomEntryId CreateTestSbomEntryId() => private static SbomEntryId CreateTestSbomEntryId() =>
new("sha256:sbom123", "pkg:npm/lodash", "4.17.21"); new($"sha256:{new string('0', 64)}", "pkg:npm/lodash", "4.17.21");
private static EvidenceId CreateTestEvidenceId(string suffix) => private static EvidenceId CreateTestEvidenceId(string suffix) =>
new($"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6{suffix.PadLeft(4, '0')}"[..64]); new($"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6{suffix.PadLeft(4, '0')}"[..64]);

View File

@@ -43,16 +43,22 @@ public class ContentAddressedIdTests
} }
[Theory] [Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("invalid")] [InlineData("invalid")]
[InlineData(":digest")] [InlineData(":digest")]
[InlineData("algo:")] [InlineData("algo:")]
public void Parse_InvalidFormat_Throws(string input) public void Parse_InvalidFormat_ThrowsFormatException(string input)
{ {
Assert.Throws<FormatException>(() => ContentAddressedId.Parse(input)); Assert.Throws<FormatException>(() => ContentAddressedId.Parse(input));
} }
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Parse_EmptyOrWhitespace_ThrowsArgumentException(string input)
{
Assert.Throws<ArgumentException>(() => ContentAddressedId.Parse(input));
}
[Fact] [Fact]
public void Parse_InvalidDigestLength_Throws() public void Parse_InvalidDigestLength_Throws()
{ {
@@ -68,26 +74,6 @@ public class ContentAddressedIdTests
Assert.Equal(input, id.ToString()); Assert.Equal(input, id.ToString());
} }
[Fact]
public void TrySplit_ValidInput_ReturnsTrue()
{
var valid = ContentAddressedId.TrySplit(
"sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
out var algorithm,
out var digest);
Assert.True(valid);
Assert.Equal("sha256", algorithm);
Assert.NotEmpty(digest);
}
[Fact]
public void TrySplit_InvalidInput_ReturnsFalse()
{
var valid = ContentAddressedId.TrySplit("invalid", out _, out _);
Assert.False(valid);
}
} }
public class EvidenceIdTests public class EvidenceIdTests
@@ -153,12 +139,14 @@ public class ProofBundleIdTests
public class SbomEntryIdTests public class SbomEntryIdTests
{ {
private static readonly string SbomDigest = $"sha256:{new string('a', 64)}";
[Fact] [Fact]
public void Constructor_WithVersion_CreatesId() public void Constructor_WithVersion_CreatesId()
{ {
var id = new SbomEntryId("sha256:abc123", "pkg:npm/lodash", "4.17.21"); var id = new SbomEntryId(SbomDigest, "pkg:npm/lodash", "4.17.21");
Assert.Equal("sha256:abc123", id.SbomDigest); Assert.Equal(SbomDigest, id.SbomDigest);
Assert.Equal("pkg:npm/lodash", id.Purl); Assert.Equal("pkg:npm/lodash", id.Purl);
Assert.Equal("4.17.21", id.Version); Assert.Equal("4.17.21", id.Version);
} }
@@ -166,9 +154,9 @@ public class SbomEntryIdTests
[Fact] [Fact]
public void Constructor_WithoutVersion_CreatesId() public void Constructor_WithoutVersion_CreatesId()
{ {
var id = new SbomEntryId("sha256:abc123", "pkg:npm/lodash"); var id = new SbomEntryId(SbomDigest, "pkg:npm/lodash");
Assert.Equal("sha256:abc123", id.SbomDigest); Assert.Equal(SbomDigest, id.SbomDigest);
Assert.Equal("pkg:npm/lodash", id.Purl); Assert.Equal("pkg:npm/lodash", id.Purl);
Assert.Null(id.Version); Assert.Null(id.Version);
} }
@@ -176,15 +164,15 @@ public class SbomEntryIdTests
[Fact] [Fact]
public void ToString_WithVersion_IncludesVersion() public void ToString_WithVersion_IncludesVersion()
{ {
var id = new SbomEntryId("sha256:abc123", "pkg:npm/lodash", "4.17.21"); var id = new SbomEntryId(SbomDigest, "pkg:npm/lodash", "4.17.21");
Assert.Equal("sha256:abc123:pkg:npm/lodash@4.17.21", id.ToString()); Assert.Equal($"{SbomDigest}:pkg:npm/lodash@4.17.21", id.ToString());
} }
[Fact] [Fact]
public void ToString_WithoutVersion_OmitsVersion() public void ToString_WithoutVersion_OmitsVersion()
{ {
var id = new SbomEntryId("sha256:abc123", "pkg:npm/lodash"); var id = new SbomEntryId(SbomDigest, "pkg:npm/lodash");
Assert.Equal("sha256:abc123:pkg:npm/lodash", id.ToString()); Assert.Equal($"{SbomDigest}:pkg:npm/lodash", id.ToString());
} }
} }

View File

@@ -6,18 +6,14 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
using System.Text; using System.Text;
using System.Text.Json;
using StellaOps.Attestor.ProofChain.Json; using StellaOps.Attestor.ProofChain.Json;
namespace StellaOps.Attestor.ProofChain.Tests; namespace StellaOps.Attestor.ProofChain.Tests;
public class JsonCanonicalizerTests public sealed class JsonCanonicalizerTests
{ {
private readonly IJsonCanonicalizer _canonicalizer; private readonly IJsonCanonicalizer _canonicalizer = new Rfc8785JsonCanonicalizer();
public JsonCanonicalizerTests()
{
_canonicalizer = new JsonCanonicalizer();
}
[Fact] [Fact]
public void Canonicalize_SortsKeys() public void Canonicalize_SortsKeys()
@@ -29,9 +25,8 @@ public class JsonCanonicalizerTests
Assert.Contains("\"a\":", outputStr); Assert.Contains("\"a\":", outputStr);
Assert.Contains("\"z\":", outputStr); Assert.Contains("\"z\":", outputStr);
// Verify 'a' comes before 'z' var aIndex = outputStr.IndexOf("\"a\":", StringComparison.Ordinal);
var aIndex = outputStr.IndexOf("\"a\":"); var zIndex = outputStr.IndexOf("\"z\":", StringComparison.Ordinal);
var zIndex = outputStr.IndexOf("\"z\":");
Assert.True(aIndex < zIndex, "Keys should be sorted alphabetically"); Assert.True(aIndex < zIndex, "Keys should be sorted alphabetically");
} }
@@ -43,17 +38,18 @@ public class JsonCanonicalizerTests
var outputStr = Encoding.UTF8.GetString(output); var outputStr = Encoding.UTF8.GetString(output);
Assert.DoesNotContain(" ", outputStr); Assert.DoesNotContain(" ", outputStr);
Assert.Equal("{\"key\":\"value\"}", outputStr);
} }
[Fact] [Fact]
public void Canonicalize_PreservesUtf8() public void Canonicalize_PreservesUnicodeContent()
{ {
var input = """{"text": "hello 🌍"}"""u8; var text = "hello 世界 \U0001F30D";
var input = JsonSerializer.SerializeToUtf8Bytes(new { text });
var output = _canonicalizer.Canonicalize(input); var output = _canonicalizer.Canonicalize(input);
var outputStr = Encoding.UTF8.GetString(output); using var document = JsonDocument.Parse(output);
Assert.Contains("世界", outputStr); Assert.Equal(text, document.RootElement.GetProperty("text").GetString());
Assert.Contains("🌍", outputStr);
} }
[Fact] [Fact]
@@ -67,20 +63,6 @@ public class JsonCanonicalizerTests
Assert.Equal(output1, output2); Assert.Equal(output1, output2);
} }
[Fact]
public void Canonicalize_NestedObjects_SortsAllLevels()
{
var input = """{"outer": {"z": 1, "a": 2}, "inner": {"y": 3, "b": 4}}"""u8;
var output = _canonicalizer.Canonicalize(input);
var outputStr = Encoding.UTF8.GetString(output);
// Check that nested keys are also sorted
var nestedA = outputStr.IndexOf("\"a\":");
var nestedZ = outputStr.IndexOf("\"z\":");
Assert.True(nestedA < nestedZ, "Nested keys should be sorted");
}
[Fact] [Fact]
public void Canonicalize_Arrays_PreservesOrder() public void Canonicalize_Arrays_PreservesOrder()
{ {
@@ -91,16 +73,6 @@ public class JsonCanonicalizerTests
Assert.Contains("[3,1,2]", outputStr); Assert.Contains("[3,1,2]", outputStr);
} }
[Fact]
public void Canonicalize_NullValue_Preserved()
{
var input = """{"key": null}"""u8;
var output = _canonicalizer.Canonicalize(input);
var outputStr = Encoding.UTF8.GetString(output);
Assert.Contains("null", outputStr);
}
[Fact] [Fact]
public void Canonicalize_BooleanValues_LowerCase() public void Canonicalize_BooleanValues_LowerCase()
{ {
@@ -114,18 +86,6 @@ public class JsonCanonicalizerTests
Assert.DoesNotContain("False", outputStr); Assert.DoesNotContain("False", outputStr);
} }
[Fact]
public void Canonicalize_Numbers_MinimalRepresentation()
{
var input = """{"integer": 42, "float": 3.14, "zero": 0}"""u8;
var output = _canonicalizer.Canonicalize(input);
var outputStr = Encoding.UTF8.GetString(output);
Assert.Contains("42", outputStr);
Assert.Contains("3.14", outputStr);
Assert.Contains("0", outputStr);
}
[Fact] [Fact]
public void Canonicalize_EmptyObject_ReturnsEmptyBraces() public void Canonicalize_EmptyObject_ReturnsEmptyBraces()
{ {
@@ -135,90 +95,5 @@ public class JsonCanonicalizerTests
var outputStr = Encoding.UTF8.GetString(output); var outputStr = Encoding.UTF8.GetString(output);
Assert.Equal("{}", outputStr); Assert.Equal("{}", outputStr);
} }
[Fact]
public void Canonicalize_EmptyArray_ReturnsEmptyBrackets()
{
var input = """{"arr": []}"""u8;
var output = _canonicalizer.Canonicalize(input);
var outputStr = Encoding.UTF8.GetString(output);
Assert.Contains("[]", outputStr);
}
[Fact]
public void Canonicalize_StringEscaping_Preserved()
{
var input = """{"text": "line1\nline2\ttab"}"""u8;
var output = _canonicalizer.Canonicalize(input);
var outputStr = Encoding.UTF8.GetString(output);
Assert.Contains("\\n", outputStr);
Assert.Contains("\\t", outputStr);
}
[Theory]
[InlineData("""{"a":1}""")]
[InlineData("""{"a":1,"b":2}""")]
[InlineData("""{"nested":{"key":"value"}}""")]
[InlineData("""{"array":[1,2,3]}""")]
public void Canonicalize_AlreadyCanonical_Unchanged(string input)
{
var inputBytes = Encoding.UTF8.GetBytes(input);
var output = _canonicalizer.Canonicalize(inputBytes);
var outputStr = Encoding.UTF8.GetString(output);
Assert.Equal(input, outputStr);
}
[Fact]
public void Canonicalize_ComplexNesting_Deterministic()
{
var input = """
{
"level1": {
"z": {
"y": 1,
"x": 2
},
"a": {
"b": 3,
"a": 4
}
},
"array": [
{"z": 1, "a": 2},
{"y": 3, "b": 4}
]
}
"""u8;
var output1 = _canonicalizer.Canonicalize(input);
var output2 = _canonicalizer.Canonicalize(input);
Assert.Equal(output1, output2);
var outputStr = Encoding.UTF8.GetString(output1);
Assert.DoesNotContain("\n", outputStr);
Assert.DoesNotContain(" ", outputStr);
}
[Fact]
public void CanonicalizeDifferentWhitespace_ProducesSameOutput()
{
var input1 = """{"key":"value"}"""u8;
var input2 = """{ "key" : "value" }"""u8;
var input3 = """
{
"key": "value"
}
"""u8;
var output1 = _canonicalizer.Canonicalize(input1);
var output2 = _canonicalizer.Canonicalize(input2);
var output3 = _canonicalizer.Canonicalize(input3);
Assert.Equal(output1, output2);
Assert.Equal(output2, output3);
}
} }

View File

@@ -104,14 +104,11 @@ public class MerkleTreeBuilderTests
} }
[Fact] [Fact]
public void ComputeMerkleRoot_EmptyLeaves_ReturnsEmptyOrZeroHash() public void ComputeMerkleRoot_EmptyLeaves_Throws()
{ {
var leaves = Array.Empty<ReadOnlyMemory<byte>>(); var leaves = Array.Empty<ReadOnlyMemory<byte>>();
// Should handle gracefully (either empty or zero hash) Assert.Throws<ArgumentException>(() => _builder.ComputeMerkleRoot(leaves));
var root = _builder.ComputeMerkleRoot(leaves);
Assert.NotNull(root);
} }
[Fact] [Fact]

View File

@@ -243,7 +243,7 @@ public class ProofSpineAssemblyIntegrationTests
leaves.Add(Encoding.UTF8.GetBytes(vexVerdictId)); leaves.Add(Encoding.UTF8.GetBytes(vexVerdictId));
// Build merkle tree // Build merkle tree
return _builder.ComputeMerkleRoot(leaves.ToArray()); return _builder.ComputeMerkleRoot(leaves);
} }
private static string FormatAsId(byte[] hash) private static string FormatAsId(byte[] hash)
@@ -251,65 +251,3 @@ public class ProofSpineAssemblyIntegrationTests
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
} }
} }
/// <summary>
/// Interface for merkle tree building.
/// </summary>
public interface IMerkleTreeBuilder
{
byte[] ComputeMerkleRoot(ReadOnlyMemory<byte>[] leaves);
}
/// <summary>
/// Deterministic merkle tree builder using SHA-256.
/// </summary>
public class DeterministicMerkleTreeBuilder : IMerkleTreeBuilder
{
public byte[] ComputeMerkleRoot(ReadOnlyMemory<byte>[] leaves)
{
if (leaves.Length == 0)
{
return new byte[32]; // Zero hash for empty tree
}
// Hash all leaves
var currentLevel = new List<byte[]>();
using var sha256 = System.Security.Cryptography.SHA256.Create();
foreach (var leaf in leaves)
{
currentLevel.Add(sha256.ComputeHash(leaf.ToArray()));
}
// Pad to power of 2 by duplicating last leaf
while (!IsPowerOfTwo(currentLevel.Count))
{
currentLevel.Add(currentLevel[^1]);
}
// Build tree bottom-up
while (currentLevel.Count > 1)
{
var nextLevel = new List<byte[]>();
for (int i = 0; i < currentLevel.Count; i += 2)
{
var left = currentLevel[i];
var right = currentLevel[i + 1];
// Concatenate and hash
var combined = new byte[left.Length + right.Length];
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
nextLevel.Add(sha256.ComputeHash(combined));
}
currentLevel = nextLevel;
}
return currentLevel[0];
}
private static bool IsPowerOfTwo(int n) => n > 0 && (n & (n - 1)) == 0;
}

View File

@@ -0,0 +1,122 @@
using FluentAssertions;
using Org.BouncyCastle.Crypto.Parameters;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.ProofChain.Builders;
using StellaOps.Attestor.ProofChain.Json;
using StellaOps.Attestor.ProofChain.Signing;
using StellaOps.Attestor.ProofChain.Statements;
namespace StellaOps.Attestor.ProofChain.Tests.Signing;
public sealed class ProofChainSignerTests
{
private static readonly DateTimeOffset FixedTime = new(2025, 12, 17, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task SignThenVerify_EvidenceStatement_Passes()
{
var (signer, keyId) = CreateSigner();
var statement = CreateEvidenceStatement(evidenceId: $"sha256:{new string('0', 64)}");
var envelope = await signer.SignStatementAsync(statement, SigningKeyProfile.Evidence);
envelope.PayloadType.Should().Be(ProofChainSigner.InTotoPayloadType);
envelope.Signatures.Should().ContainSingle();
envelope.Signatures[0].KeyId.Should().Be(keyId);
envelope.Signatures[0].Sig.Should().NotBeNullOrWhiteSpace();
envelope.Payload.Should().NotBeNullOrWhiteSpace();
var result = await signer.VerifyEnvelopeAsync(envelope, new[] { keyId });
result.IsValid.Should().BeTrue();
result.KeyId.Should().Be(keyId);
}
[Fact]
public async Task Verify_TamperedPayload_Fails()
{
var (signer, keyId) = CreateSigner();
var statement = CreateEvidenceStatement(evidenceId: $"sha256:{new string('1', 64)}");
var envelope = await signer.SignStatementAsync(statement, SigningKeyProfile.Evidence);
var payloadBytes = Convert.FromBase64String(envelope.Payload);
payloadBytes[^1] ^= 0xff;
var tampered = envelope with { Payload = Convert.ToBase64String(payloadBytes) };
var result = await signer.VerifyEnvelopeAsync(tampered, new[] { keyId });
result.IsValid.Should().BeFalse();
}
[Fact]
public async Task CrossPlatformVector_Ed25519Signature_IsStable()
{
var (signer, keyId) = CreateSigner(keyIdOverride: "test-key");
var statement = CreateEvidenceStatement(evidenceId: $"sha256:{new string('2', 64)}");
var envelope = await signer.SignStatementAsync(statement, SigningKeyProfile.Evidence);
envelope.Signatures[0].KeyId.Should().Be(keyId);
// Filled in after the first successful run to lock the vector across platforms/implementations.
const string expectedSig = "zJtzdRX76ENKf4IePv5AyTxqdS2YlVMcseaw2UBh1eBhfarUNq2AdiKyxVMWPftSy2uJJGfo7R7BilQO+Xj8AA==";
envelope.Signatures[0].Sig.Should().Be(expectedSig);
}
private static EvidenceStatement CreateEvidenceStatement(string evidenceId)
{
var builder = new StatementBuilder();
var subject = new ProofSubject
{
Name = "image:demo",
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
};
var predicate = new EvidencePayload
{
Source = "trivy",
SourceVersion = "0.50.0",
CollectionTime = FixedTime,
SbomEntryId = "sha256:sbom:pkg:npm/lodash@4.17.21",
VulnerabilityId = "CVE-2025-1234",
RawFinding = new { severity = "high" },
EvidenceId = evidenceId
};
return builder.BuildEvidenceStatement(subject, predicate);
}
private static (IProofChainSigner Signer, string KeyId) CreateSigner(string? keyIdOverride = null)
{
var seed = Enumerable.Range(0, 32).Select(static i => (byte)i).ToArray();
var privateKey = new Ed25519PrivateKeyParameters(seed, 0);
var publicKey = privateKey.GeneratePublicKey().GetEncoded();
var key = EnvelopeKey.CreateEd25519Signer(seed, publicKey, keyId: keyIdOverride ?? "proofchain-test-key");
var keyStore = new StaticKeyStore(new Dictionary<SigningKeyProfile, EnvelopeKey>
{
[SigningKeyProfile.Evidence] = key
});
return (new ProofChainSigner(keyStore, new Rfc8785JsonCanonicalizer()), key.KeyId);
}
private sealed class StaticKeyStore : IProofChainKeyStore
{
private readonly IReadOnlyDictionary<SigningKeyProfile, EnvelopeKey> _signingKeys;
private readonly IReadOnlyDictionary<string, EnvelopeKey> _verificationKeys;
public StaticKeyStore(IReadOnlyDictionary<SigningKeyProfile, EnvelopeKey> signingKeys)
{
_signingKeys = signingKeys;
_verificationKeys = signingKeys.Values.ToDictionary(static key => key.KeyId, static key => key, StringComparer.Ordinal);
}
public bool TryGetSigningKey(SigningKeyProfile profile, out EnvelopeKey key)
=> _signingKeys.TryGetValue(profile, out key!);
public bool TryGetVerificationKey(string keyId, out EnvelopeKey key)
=> _verificationKeys.TryGetValue(keyId, out key!);
}
}

View File

@@ -8,191 +8,130 @@ using StellaOps.Attestor.ProofChain.Statements;
namespace StellaOps.Attestor.ProofChain.Tests.Statements; namespace StellaOps.Attestor.ProofChain.Tests.Statements;
/// <summary> /// <summary>
/// Unit tests for all DSSE statement types (Task PROOF-PRED-0012). /// Unit tests for proof chain statement construction (Task PROOF-PRED-0012).
/// </summary> /// </summary>
public class StatementBuilderTests public sealed class StatementBuilderTests
{ {
private readonly StatementBuilder _builder = new(); private readonly StatementBuilder _builder = new();
private readonly DateTimeOffset _fixedTime = new(2025, 12, 16, 10, 0, 0, TimeSpan.Zero); private readonly DateTimeOffset _fixedTime = new(2025, 12, 16, 10, 0, 0, TimeSpan.Zero);
[Fact] [Fact]
public void BuildEvidenceStatement_SetsPredicateType() public void BuildEvidenceStatement_SetsPredicateTypeAndSubject()
{ {
var statement = _builder.BuildEvidenceStatement( var subject = CreateSubject("image:demo", "abc123");
subject: new InTotoSubject { Name = "test-artifact", Digest = new() { ["sha256"] = "abc123" } }, var predicate = new EvidencePayload
source: "trivy", {
sourceVersion: "0.50.0", Source = "trivy",
collectionTime: _fixedTime, SourceVersion = "0.50.0",
sbomEntryId: "sbom-123"); CollectionTime = _fixedTime,
SbomEntryId = "sha256:sbom:pkg:npm/lodash@4.17.21",
VulnerabilityId = "CVE-2025-1234",
RawFinding = new { severity = "high" },
EvidenceId = $"sha256:{new string('0', 64)}"
};
var statement = _builder.BuildEvidenceStatement(subject, predicate);
Assert.Equal("evidence.stella/v1", statement.PredicateType);
Assert.Equal("https://in-toto.io/Statement/v1", statement.Type); Assert.Equal("https://in-toto.io/Statement/v1", statement.Type);
} Assert.Equal("evidence.stella/v1", statement.PredicateType);
Assert.Single(statement.Subject);
[Fact] Assert.Equal(subject.Name, statement.Subject[0].Name);
public void BuildEvidenceStatement_PopulatesPredicate() Assert.Equal("abc123", statement.Subject[0].Digest["sha256"]);
{
var statement = _builder.BuildEvidenceStatement(
subject: new InTotoSubject { Name = "test-artifact", Digest = new() { ["sha256"] = "abc123" } },
source: "trivy",
sourceVersion: "0.50.0",
collectionTime: _fixedTime,
sbomEntryId: "sbom-123",
vulnerabilityId: "CVE-2025-1234");
Assert.Equal("trivy", statement.Predicate.Source); Assert.Equal("trivy", statement.Predicate.Source);
Assert.Equal("0.50.0", statement.Predicate.SourceVersion);
Assert.Equal(_fixedTime, statement.Predicate.CollectionTime);
Assert.Equal("sbom-123", statement.Predicate.SbomEntryId);
Assert.Equal("CVE-2025-1234", statement.Predicate.VulnerabilityId); Assert.Equal("CVE-2025-1234", statement.Predicate.VulnerabilityId);
} }
[Fact] [Fact]
public void BuildProofSpineStatement_SetsPredicateType() public void BuildSbomLinkageStatement_SetsAllSubjects()
{ {
var statement = _builder.BuildProofSpineStatement( var subjects = new[]
subject: new InTotoSubject { Name = "image:v1.0", Digest = new() { ["sha256"] = "abc123" } }, {
spineAlgorithm: "sha256-merkle", CreateSubject("image:demo", "abc123"),
rootHash: "root-hash", CreateSubject("pkg:npm/lodash@4.17.21", "def456"),
leafHashes: ["leaf1", "leaf2", "leaf3"]); };
Assert.Equal("proofspine.stella/v1", statement.PredicateType); var predicate = new SbomLinkagePayload
{
Sbom = new SbomDescriptor
{
Id = "sbom-1",
Format = "cyclonedx",
SpecVersion = "1.6",
MediaType = "application/vnd.cyclonedx+json",
Sha256 = new string('1', 64),
Location = "file:///sboms/demo.json"
},
Generator = new GeneratorDescriptor
{
Name = "stellaops-sbomgen",
Version = "0.1.0"
},
GeneratedAt = _fixedTime,
Tags = new Dictionary<string, string> { ["env"] = "test" }
};
var statement = _builder.BuildSbomLinkageStatement(subjects, predicate);
Assert.Equal("https://stella-ops.org/predicates/sbom-linkage/v1", statement.PredicateType);
Assert.Equal(2, statement.Subject.Count);
Assert.Equal(subjects[0].Name, statement.Subject[0].Name);
Assert.Equal(subjects[1].Name, statement.Subject[1].Name);
} }
[Fact] [Fact]
public void BuildProofSpineStatement_ContainsLeafHashes() public void BuildSbomLinkageStatement_EmptySubjects_Throws()
{ {
var leafHashes = new[] { "hash1", "hash2", "hash3", "hash4" }; var predicate = new SbomLinkagePayload
var statement = _builder.BuildProofSpineStatement( {
subject: new InTotoSubject { Name = "image:v1.0", Digest = new() { ["sha256"] = "abc123" } }, Sbom = new SbomDescriptor
spineAlgorithm: "sha256-merkle", {
rootHash: "merkle-root", Id = "sbom-1",
leafHashes: leafHashes); Format = "cyclonedx",
SpecVersion = "1.6",
MediaType = "application/vnd.cyclonedx+json",
Sha256 = new string('1', 64)
},
Generator = new GeneratorDescriptor
{
Name = "stellaops-sbomgen",
Version = "0.1.0"
},
GeneratedAt = _fixedTime
};
Assert.Equal("sha256-merkle", statement.Predicate.Algorithm); Assert.Throws<ArgumentException>(() => _builder.BuildSbomLinkageStatement(Array.Empty<ProofSubject>(), predicate));
Assert.Equal("merkle-root", statement.Predicate.RootHash);
Assert.Equal(4, statement.Predicate.LeafHashes.Length);
}
[Fact]
public void BuildVexVerdictStatement_SetsPredicateType()
{
var statement = _builder.BuildVexVerdictStatement(
subject: new InTotoSubject { Name = "pkg:npm/lodash@4.17.21", Digest = new() { ["sha256"] = "abc123" } },
vulnerabilityId: "CVE-2025-1234",
vexStatus: "not_affected",
justification: "vulnerable_code_not_present",
analysisTime: _fixedTime);
Assert.Equal("vexverdict.stella/v1", statement.PredicateType);
}
[Fact]
public void BuildVexVerdictStatement_PopulatesVexDetails()
{
var statement = _builder.BuildVexVerdictStatement(
subject: new InTotoSubject { Name = "pkg:npm/lodash@4.17.21", Digest = new() { ["sha256"] = "abc123" } },
vulnerabilityId: "CVE-2025-1234",
vexStatus: "not_affected",
justification: "vulnerable_code_not_present",
analysisTime: _fixedTime);
Assert.Equal("CVE-2025-1234", statement.Predicate.VulnerabilityId);
Assert.Equal("not_affected", statement.Predicate.Status);
Assert.Equal("vulnerable_code_not_present", statement.Predicate.Justification);
}
[Fact]
public void BuildReasoningStatement_SetsPredicateType()
{
var statement = _builder.BuildReasoningStatement(
subject: new InTotoSubject { Name = "finding:123", Digest = new() { ["sha256"] = "abc123" } },
reasoningType: "exploitability",
conclusion: "not_exploitable",
evidenceRefs: ["evidence1", "evidence2"]);
Assert.Equal("reasoning.stella/v1", statement.PredicateType);
}
[Fact]
public void BuildVerdictReceiptStatement_SetsPredicateType()
{
var statement = _builder.BuildVerdictReceiptStatement(
subject: new InTotoSubject { Name = "scan:456", Digest = new() { ["sha256"] = "abc123" } },
verdictHash: "verdict-hash",
verdictTime: _fixedTime,
signatureAlgorithm: "ECDSA-P256");
Assert.Equal("verdictreceipt.stella/v1", statement.PredicateType);
}
[Fact]
public void BuildSbomLinkageStatement_SetsPredicateType()
{
var statement = _builder.BuildSbomLinkageStatement(
subject: new InTotoSubject { Name = "image:v1.0", Digest = new() { ["sha256"] = "abc123" } },
sbomDigest: "sbom-digest",
sbomFormat: "cyclonedx",
sbomVersion: "1.6");
Assert.Equal("sbomlinkage.stella/v1", statement.PredicateType);
}
[Fact]
public void AllStatements_SerializeToValidJson()
{
var subject = new InTotoSubject { Name = "test", Digest = new() { ["sha256"] = "abc" } };
var evidence = _builder.BuildEvidenceStatement(subject, "trivy", "1.0", _fixedTime, "sbom1");
var spine = _builder.BuildProofSpineStatement(subject, "sha256", "root", ["leaf1"]);
var vex = _builder.BuildVexVerdictStatement(subject, "CVE-1", "fixed", null, _fixedTime);
var reasoning = _builder.BuildReasoningStatement(subject, "exploitability", "safe", []);
var receipt = _builder.BuildVerdictReceiptStatement(subject, "hash", _fixedTime, "ECDSA");
var sbom = _builder.BuildSbomLinkageStatement(subject, "sbom-hash", "spdx", "3.0");
// All should serialize without throwing
Assert.NotNull(JsonSerializer.Serialize(evidence));
Assert.NotNull(JsonSerializer.Serialize(spine));
Assert.NotNull(JsonSerializer.Serialize(vex));
Assert.NotNull(JsonSerializer.Serialize(reasoning));
Assert.NotNull(JsonSerializer.Serialize(receipt));
Assert.NotNull(JsonSerializer.Serialize(sbom));
} }
[Fact] [Fact]
public void EvidenceStatement_RoundTripsViaJson() public void EvidenceStatement_RoundTripsViaJson()
{ {
var original = _builder.BuildEvidenceStatement( var subject = CreateSubject("image:demo", "abc123");
subject: new InTotoSubject { Name: "artifact", Digest = new() { ["sha256"] = "hash123" } }, var statement = _builder.BuildEvidenceStatement(subject, new EvidencePayload
source: "grype", {
sourceVersion: "0.80.0", Source = "grype",
collectionTime: _fixedTime, SourceVersion = "0.80.0",
sbomEntryId: "entry-456", CollectionTime = _fixedTime,
vulnerabilityId: "CVE-2025-9999"); SbomEntryId = "sha256:sbom:pkg:npm/lodash@4.17.21",
VulnerabilityId = "CVE-2025-9999",
RawFinding = "raw",
EvidenceId = $"sha256:{new string('2', 64)}"
});
var json = JsonSerializer.Serialize(original); var json = JsonSerializer.Serialize(statement);
var restored = JsonSerializer.Deserialize<EvidenceStatement>(json); var restored = JsonSerializer.Deserialize<EvidenceStatement>(json);
Assert.NotNull(restored); Assert.NotNull(restored);
Assert.Equal(original.PredicateType, restored.PredicateType); Assert.Equal(statement.PredicateType, restored.PredicateType);
Assert.Equal(original.Predicate.Source, restored.Predicate.Source); Assert.Equal(statement.Subject[0].Name, restored.Subject[0].Name);
Assert.Equal(original.Predicate.VulnerabilityId, restored.Predicate.VulnerabilityId); Assert.Equal(statement.Predicate.EvidenceId, restored.Predicate.EvidenceId);
Assert.Equal(statement.Predicate.VulnerabilityId, restored.Predicate.VulnerabilityId);
} }
[Fact] private static ProofSubject CreateSubject(string name, string sha256Digest)
public void ProofSpineStatement_RoundTripsViaJson() => new()
{ {
var original = _builder.BuildProofSpineStatement( Name = name,
subject: new InTotoSubject { Name = "image:latest", Digest = new() { ["sha256"] = "img-hash" } }, Digest = new Dictionary<string, string> { ["sha256"] = sha256Digest }
spineAlgorithm: "sha256-merkle-v2", };
rootHash: "merkle-root-abc",
leafHashes: ["a", "b", "c", "d"]);
var json = JsonSerializer.Serialize(original);
var restored = JsonSerializer.Deserialize<ProofSpineStatement>(json);
Assert.NotNull(restored);
Assert.Equal(original.Predicate.RootHash, restored.Predicate.RootHash);
Assert.Equal(original.Predicate.LeafHashes.Length, restored.Predicate.LeafHashes.Length);
}
} }

View File

@@ -1,172 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps Contributors
using System.Text.Json;
using StellaOps.Attestor.ProofChain.Builders;
using StellaOps.Attestor.ProofChain.Statements;
using StellaOps.Attestor.ProofChain.Validation;
namespace StellaOps.Attestor.ProofChain.Tests.Statements;
/// <summary>
/// Unit tests for statement validation (Task PROOF-PRED-0015).
/// </summary>
public class StatementValidatorTests
{
private readonly StatementBuilder _builder = new();
private readonly IStatementValidator _validator = new StatementValidator();
private readonly DateTimeOffset _fixedTime = new(2025, 12, 16, 10, 0, 0, TimeSpan.Zero);
[Fact]
public void Validate_ValidEvidenceStatement_ReturnsSuccess()
{
var statement = _builder.BuildEvidenceStatement(
subject: new InTotoSubject { Name = "artifact", Digest = new() { ["sha256"] = "abc123" } },
source: "trivy",
sourceVersion: "0.50.0",
collectionTime: _fixedTime,
sbomEntryId: "sbom-123");
var result = _validator.Validate(statement);
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public void Validate_EvidenceStatementWithEmptySource_ReturnsError()
{
var statement = new EvidenceStatement
{
Subject = [new InTotoSubject { Name = "artifact", Digest = new() { ["sha256"] = "abc" } }],
Predicate = new EvidencePayload
{
Source = "",
SourceVersion = "1.0",
CollectionTime = _fixedTime,
SbomEntryId = "sbom-1"
}
};
var result = _validator.Validate(statement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Source"));
}
[Fact]
public void Validate_StatementWithEmptySubject_ReturnsError()
{
var statement = new EvidenceStatement
{
Subject = [],
Predicate = new EvidencePayload
{
Source = "trivy",
SourceVersion = "1.0",
CollectionTime = _fixedTime,
SbomEntryId = "sbom-1"
}
};
var result = _validator.Validate(statement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Subject"));
}
[Fact]
public void Validate_ProofSpineWithEmptyLeafHashes_ReturnsError()
{
var statement = new ProofSpineStatement
{
Subject = [new InTotoSubject { Name = "image", Digest = new() { ["sha256"] = "hash" } }],
Predicate = new ProofSpinePayload
{
Algorithm = "sha256-merkle",
RootHash = "root",
LeafHashes = []
}
};
var result = _validator.Validate(statement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("LeafHashes"));
}
[Fact]
public void Validate_VexVerdictWithValidStatus_ReturnsSuccess()
{
var validStatuses = new[] { "not_affected", "affected", "fixed", "under_investigation" };
foreach (var status in validStatuses)
{
var statement = _builder.BuildVexVerdictStatement(
subject: new InTotoSubject { Name = "pkg", Digest = new() { ["sha256"] = "abc" } },
vulnerabilityId: "CVE-2025-1",
vexStatus: status,
justification: null,
analysisTime: _fixedTime);
var result = _validator.Validate(statement);
Assert.True(result.IsValid, $"Status '{status}' should be valid");
}
}
[Fact]
public void Validate_VexVerdictWithInvalidStatus_ReturnsError()
{
var statement = new VexVerdictStatement
{
Subject = [new InTotoSubject { Name = "pkg", Digest = new() { ["sha256"] = "abc" } }],
Predicate = new VexVerdictPayload
{
VulnerabilityId = "CVE-2025-1",
Status = "invalid_status",
AnalysisTime = _fixedTime
}
};
var result = _validator.Validate(statement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Status"));
}
[Fact]
public void Validate_ReasoningStatementWithEvidence_ReturnsSuccess()
{
var statement = _builder.BuildReasoningStatement(
subject: new InTotoSubject { Name = "finding", Digest = new() { ["sha256"] = "abc" } },
reasoningType: "exploitability",
conclusion: "not_exploitable",
evidenceRefs: ["evidence-1", "evidence-2"]);
var result = _validator.Validate(statement);
Assert.True(result.IsValid);
}
[Fact]
public void Validate_SubjectWithMissingDigest_ReturnsError()
{
var statement = new EvidenceStatement
{
Subject = [new InTotoSubject { Name = "artifact", Digest = new() }],
Predicate = new EvidencePayload
{
Source = "trivy",
SourceVersion = "1.0",
CollectionTime = _fixedTime,
SbomEntryId = "sbom-1"
}
};
var result = _validator.Validate(statement);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Digest"));
}
}

View File

@@ -14,7 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.7.24407.12" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" /> <PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
@@ -26,7 +26,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include=\"..\\..\\__Libraries\\StellaOps.Attestor.ProofChain\\StellaOps.Attestor.ProofChain.csproj\" /> <ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,465 +0,0 @@
// -----------------------------------------------------------------------------
// VerificationPipelineIntegrationTests.cs
// Sprint: SPRINT_0501_0001_0001_proof_evidence_chain_master
// Task: PROOF-MASTER-0002
// Description: Integration tests for the full proof chain verification pipeline
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using StellaOps.Attestor.ProofChain.Identifiers;
using StellaOps.Attestor.ProofChain.Verification;
using Xunit;
namespace StellaOps.Attestor.ProofChain.Tests.Verification;
/// <summary>
/// Integration tests for the verification pipeline.
/// Tests PROOF-MASTER-0002: Full proof chain verification flow.
/// </summary>
public class VerificationPipelineIntegrationTests
{
private readonly IProofBundleStore _proofStore;
private readonly IDsseVerifier _dsseVerifier;
private readonly IRekorVerifier _rekorVerifier;
private readonly ITrustAnchorResolver _trustAnchorResolver;
private readonly ILogger<VerificationPipeline> _logger;
private readonly FakeTimeProvider _timeProvider;
public VerificationPipelineIntegrationTests()
{
_proofStore = Substitute.For<IProofBundleStore>();
_dsseVerifier = Substitute.For<IDsseVerifier>();
_rekorVerifier = Substitute.For<IRekorVerifier>();
_trustAnchorResolver = Substitute.For<ITrustAnchorResolver>();
_logger = NullLogger<VerificationPipeline>.Instance;
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 17, 12, 0, 0, TimeSpan.Zero));
}
#region Full Pipeline Tests
[Fact]
public async Task VerifyAsync_ValidProofBundle_AllStepsPass()
{
// Arrange
var bundleId = new ProofBundleId("sha256:valid123");
var keyId = "key-1";
SetupValidBundle(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupValidRekorVerification();
SetupValidTrustAnchor(keyId);
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = true,
VerifierVersion = "1.0.0-test"
};
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
result.IsValid.Should().BeTrue();
result.Receipt.Result.Should().Be(VerificationResult.Pass);
result.Steps.Should().HaveCount(4);
result.Steps.Should().OnlyContain(s => s.Passed);
result.FirstFailure.Should().BeNull();
}
[Fact]
public async Task VerifyAsync_InvalidDsseSignature_FailsAtFirstStep()
{
// Arrange
var bundleId = new ProofBundleId("sha256:invalid-sig");
var keyId = "key-1";
SetupValidBundle(bundleId, keyId);
SetupInvalidDsseVerification(keyId, "Signature mismatch");
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Receipt.Result.Should().Be(VerificationResult.Fail);
result.FirstFailure.Should().NotBeNull();
result.FirstFailure!.StepName.Should().Be("dsse_signature");
result.Receipt.FailureReason.Should().Contain("Signature mismatch");
}
[Fact]
public async Task VerifyAsync_IdMismatch_FailsAtIdRecomputation()
{
// Arrange
var bundleId = new ProofBundleId("sha256:wrong-id");
var keyId = "key-1";
SetupBundleWithWrongId(bundleId, keyId);
SetupValidDsseVerification(keyId);
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Steps.Should().Contain(s => s.StepName == "id_recomputation" && !s.Passed);
}
[Fact]
public async Task VerifyAsync_NoRekorEntry_FailsAtRekorStep()
{
// Arrange
var bundleId = new ProofBundleId("sha256:no-rekor");
var keyId = "key-1";
SetupBundleWithoutRekor(bundleId, keyId);
SetupValidDsseVerification(keyId);
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = true
};
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Steps.Should().Contain(s => s.StepName == "rekor_inclusion" && !s.Passed);
}
[Fact]
public async Task VerifyAsync_RekorDisabled_SkipsRekorStep()
{
// Arrange
var bundleId = new ProofBundleId("sha256:skip-rekor");
var keyId = "key-1";
SetupBundleWithoutRekor(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupValidTrustAnchor(keyId);
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = false // Skip Rekor
};
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
var rekorStep = result.Steps.FirstOrDefault(s => s.StepName == "rekor_inclusion");
rekorStep.Should().NotBeNull();
rekorStep!.Passed.Should().BeTrue();
rekorStep.Details.Should().Contain("skipped");
}
[Fact]
public async Task VerifyAsync_UnauthorizedKey_FailsAtTrustAnchor()
{
// Arrange
var bundleId = new ProofBundleId("sha256:bad-key");
var keyId = "unauthorized-key";
SetupValidBundle(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupValidRekorVerification();
SetupTrustAnchorWithoutKey(keyId);
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = true
};
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
result.IsValid.Should().BeFalse();
result.Steps.Should().Contain(s => s.StepName == "trust_anchor" && !s.Passed);
}
#endregion
#region Receipt Generation Tests
[Fact]
public async Task VerifyAsync_GeneratesReceipt_WithCorrectFields()
{
// Arrange
var bundleId = new ProofBundleId("sha256:receipt-test");
var keyId = "key-1";
SetupValidBundle(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupValidRekorVerification();
SetupValidTrustAnchor(keyId);
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifierVersion = "2.0.0"
};
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
result.Receipt.Should().NotBeNull();
result.Receipt.ReceiptId.Should().StartWith("receipt:");
result.Receipt.VerifierVersion.Should().Be("2.0.0");
result.Receipt.ProofBundleId.Should().Be(bundleId.Value);
result.Receipt.StepsSummary.Should().HaveCount(4);
result.Receipt.TotalDurationMs.Should().BeGreaterOrEqualTo(0);
}
[Fact]
public async Task VerifyAsync_FailingPipeline_ReceiptContainsFailureReason()
{
// Arrange
var bundleId = new ProofBundleId("sha256:fail-receipt");
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
.Returns((ProofBundle?)null);
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
// Act
var result = await pipeline.VerifyAsync(request);
// Assert
result.Receipt.Result.Should().Be(VerificationResult.Fail);
result.Receipt.FailureReason.Should().NotBeNullOrEmpty();
}
#endregion
#region Cancellation Tests
[Fact]
public async Task VerifyAsync_Cancelled_ReturnsFailure()
{
// Arrange
var bundleId = new ProofBundleId("sha256:cancel-test");
var cts = new CancellationTokenSource();
cts.Cancel();
var pipeline = CreatePipeline();
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
// Act
var result = await pipeline.VerifyAsync(request, cts.Token);
// Assert
result.IsValid.Should().BeFalse();
result.Steps.Should().Contain(s => s.ErrorMessage?.Contains("cancelled") == true);
}
#endregion
#region Helper Methods
private VerificationPipeline CreatePipeline()
{
return VerificationPipeline.CreateDefault(
_proofStore,
_dsseVerifier,
_rekorVerifier,
_trustAnchorResolver,
_logger,
_timeProvider);
}
private void SetupValidBundle(ProofBundleId bundleId, string keyId)
{
var bundle = CreateTestBundle(keyId, includeRekor: true);
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
.Returns(bundle);
}
private void SetupBundleWithWrongId(ProofBundleId bundleId, string keyId)
{
// Create a bundle but the ID won't match when recomputed
var bundle = new ProofBundle
{
Statements = new List<ProofStatement>
{
new ProofStatement
{
StatementId = "sha256:wrong-statement-id", // Won't match content
PredicateType = "evidence.stella/v1",
Predicate = new { test = "data" }
}
},
Envelopes = new List<DsseEnvelope>
{
new DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
Payload = "test"u8.ToArray(),
Signatures = new List<DsseSignature>
{
new DsseSignature { KeyId = keyId, Sig = new byte[] { 0x01 } }
}
}
},
RekorLogEntry = CreateTestRekorEntry()
};
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
.Returns(bundle);
}
private void SetupBundleWithoutRekor(ProofBundleId bundleId, string keyId)
{
var bundle = CreateTestBundle(keyId, includeRekor: false);
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
.Returns(bundle);
}
private void SetupValidDsseVerification(string keyId)
{
_dsseVerifier.VerifyAsync(Arg.Any<DsseEnvelope>(), Arg.Any<CancellationToken>())
.Returns(new DsseVerificationResult { IsValid = true, KeyId = keyId });
}
private void SetupInvalidDsseVerification(string keyId, string error)
{
_dsseVerifier.VerifyAsync(Arg.Any<DsseEnvelope>(), Arg.Any<CancellationToken>())
.Returns(new DsseVerificationResult
{
IsValid = false,
KeyId = keyId,
ErrorMessage = error
});
}
private void SetupValidRekorVerification()
{
_rekorVerifier.VerifyInclusionAsync(
Arg.Any<string>(),
Arg.Any<long>(),
Arg.Any<InclusionProof>(),
Arg.Any<SignedTreeHead>(),
Arg.Any<CancellationToken>())
.Returns(new RekorVerificationResult { IsValid = true });
}
private void SetupValidTrustAnchor(string keyId)
{
var anchor = new TrustAnchorInfo
{
AnchorId = Guid.NewGuid(),
AllowedKeyIds = new List<string> { keyId },
RevokedKeyIds = new List<string>()
};
_trustAnchorResolver.GetAnchorAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(anchor);
_trustAnchorResolver.FindAnchorForProofAsync(Arg.Any<ProofBundleId>(), Arg.Any<CancellationToken>())
.Returns(anchor);
}
private void SetupTrustAnchorWithoutKey(string keyId)
{
var anchor = new TrustAnchorInfo
{
AnchorId = Guid.NewGuid(),
AllowedKeyIds = new List<string> { "different-key" },
RevokedKeyIds = new List<string>()
};
_trustAnchorResolver.FindAnchorForProofAsync(Arg.Any<ProofBundleId>(), Arg.Any<CancellationToken>())
.Returns(anchor);
}
private static ProofBundle CreateTestBundle(string keyId, bool includeRekor)
{
return new ProofBundle
{
Statements = new List<ProofStatement>
{
new ProofStatement
{
StatementId = "sha256:test-statement",
PredicateType = "evidence.stella/v1",
Predicate = new { test = "data" }
}
},
Envelopes = new List<DsseEnvelope>
{
new DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
Payload = "test"u8.ToArray(),
Signatures = new List<DsseSignature>
{
new DsseSignature { KeyId = keyId, Sig = new byte[] { 0x01 } }
}
}
},
RekorLogEntry = includeRekor ? CreateTestRekorEntry() : null
};
}
private static RekorLogEntry CreateTestRekorEntry()
{
return new RekorLogEntry
{
LogId = "test-log",
LogIndex = 12345,
InclusionProof = new InclusionProof
{
Hashes = new List<byte[]> { new byte[] { 0x01 } },
TreeSize = 1000,
RootHash = new byte[] { 0x02 }
},
SignedTreeHead = new SignedTreeHead
{
TreeSize = 1000,
RootHash = new byte[] { 0x02 },
Signature = new byte[] { 0x03 }
}
};
}
#endregion
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset initialTime)
{
_now = initialTime;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetTime(DateTimeOffset time) => _now = time;
}

View File

@@ -1,484 +0,0 @@
// -----------------------------------------------------------------------------
// VerificationPipelineTests.cs
// Sprint: SPRINT_0501_0005_0001_proof_chain_api_surface
// Task: PROOF-API-0011 - Integration tests for verification pipeline
// Description: Tests for the full verification pipeline including DSSE, ID
// recomputation, Rekor inclusion, and trust anchor verification
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Attestor.ProofChain.Identifiers;
using StellaOps.Attestor.ProofChain.Receipts;
using StellaOps.Attestor.ProofChain.Verification;
using Xunit;
namespace StellaOps.Attestor.ProofChain.Tests.Verification;
/// <summary>
/// Integration tests for the verification pipeline.
/// </summary>
public class VerificationPipelineTests
{
private readonly Mock<IProofBundleStore> _proofStoreMock;
private readonly Mock<IDsseVerifier> _dsseVerifierMock;
private readonly Mock<IRekorVerifier> _rekorVerifierMock;
private readonly Mock<ITrustAnchorResolver> _trustAnchorResolverMock;
private readonly VerificationPipeline _pipeline;
public VerificationPipelineTests()
{
_proofStoreMock = new Mock<IProofBundleStore>();
_dsseVerifierMock = new Mock<IDsseVerifier>();
_rekorVerifierMock = new Mock<IRekorVerifier>();
_trustAnchorResolverMock = new Mock<ITrustAnchorResolver>();
_pipeline = VerificationPipeline.CreateDefault(
_proofStoreMock.Object,
_dsseVerifierMock.Object,
_rekorVerifierMock.Object,
_trustAnchorResolverMock.Object,
NullLogger<VerificationPipeline>.Instance);
}
#region Full Pipeline Tests
[Fact]
public async Task VerifyAsync_AllStepsPass_ReturnsValidResult()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "test-key-id";
var anchorId = Guid.NewGuid();
SetupValidProofBundle(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupValidRekorVerification();
SetupValidTrustAnchor(anchorId, keyId);
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = true
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.True(result.IsValid);
Assert.Equal(VerificationResult.Pass, result.Receipt.Result);
Assert.All(result.Steps, step => Assert.True(step.Passed));
Assert.Null(result.FirstFailure);
}
[Fact]
public async Task VerifyAsync_DsseSignatureInvalid_FailsAtDsseStep()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "invalid-key";
SetupValidProofBundle(bundleId, keyId);
SetupInvalidDsseVerification("Signature verification failed");
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = false
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.False(result.IsValid);
Assert.Equal(VerificationResult.Fail, result.Receipt.Result);
Assert.NotNull(result.FirstFailure);
Assert.Equal("dsse_signature", result.FirstFailure.StepName);
Assert.Contains("Signature verification failed", result.FirstFailure.ErrorMessage);
}
[Fact]
public async Task VerifyAsync_IdMismatch_FailsAtIdRecomputationStep()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "test-key-id";
// Setup a bundle with mismatched ID
SetupProofBundleWithMismatchedId(bundleId, keyId);
SetupValidDsseVerification(keyId);
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = false
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.False(result.IsValid);
var idStep = result.Steps.FirstOrDefault(s => s.StepName == "id_recomputation");
Assert.NotNull(idStep);
// Note: The actual result depends on how the bundle is constructed
}
[Fact]
public async Task VerifyAsync_RekorInclusionFails_FailsAtRekorStep()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "test-key-id";
SetupValidProofBundle(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupInvalidRekorVerification("Inclusion proof invalid");
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = true
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.False(result.IsValid);
var rekorStep = result.Steps.FirstOrDefault(s => s.StepName == "rekor_inclusion");
Assert.NotNull(rekorStep);
Assert.False(rekorStep.Passed);
Assert.Contains("Inclusion proof invalid", rekorStep.ErrorMessage);
}
[Fact]
public async Task VerifyAsync_RekorDisabled_SkipsRekorStep()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "test-key-id";
var anchorId = Guid.NewGuid();
SetupValidProofBundle(bundleId, keyId, includeRekorEntry: false);
SetupValidDsseVerification(keyId);
SetupValidTrustAnchor(anchorId, keyId);
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = false
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.True(result.IsValid);
var rekorStep = result.Steps.FirstOrDefault(s => s.StepName == "rekor_inclusion");
Assert.NotNull(rekorStep);
Assert.True(rekorStep.Passed);
Assert.Contains("skipped", rekorStep.Details, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task VerifyAsync_UnauthorizedKey_FailsAtTrustAnchorStep()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "unauthorized-key";
var anchorId = Guid.NewGuid();
SetupValidProofBundle(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupTrustAnchorWithoutKey(anchorId, keyId);
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = false
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.False(result.IsValid);
var anchorStep = result.Steps.FirstOrDefault(s => s.StepName == "trust_anchor");
Assert.NotNull(anchorStep);
Assert.False(anchorStep.Passed);
Assert.Contains("not authorized", anchorStep.ErrorMessage);
}
#endregion
#region Receipt Generation Tests
[Fact]
public async Task VerifyAsync_GeneratesReceiptWithCorrectFields()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "test-key-id";
var anchorId = Guid.NewGuid();
var verifierVersion = "2.0.0";
SetupValidProofBundle(bundleId, keyId);
SetupValidDsseVerification(keyId);
SetupValidRekorVerification();
SetupValidTrustAnchor(anchorId, keyId);
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = true,
VerifierVersion = verifierVersion
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.NotNull(result.Receipt);
Assert.NotEmpty(result.Receipt.ReceiptId);
Assert.Equal(bundleId.Value, result.Receipt.ProofBundleId);
Assert.Equal(verifierVersion, result.Receipt.VerifierVersion);
Assert.True(result.Receipt.TotalDurationMs >= 0);
Assert.NotEmpty(result.Receipt.StepsSummary!);
}
[Fact]
public async Task VerifyAsync_FailedVerification_ReceiptContainsFailureReason()
{
// Arrange
var bundleId = CreateTestBundleId();
_proofStoreMock
.Setup(x => x.GetBundleAsync(bundleId, It.IsAny<CancellationToken>()))
.ReturnsAsync((ProofBundle?)null);
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = false
};
// Act
var result = await _pipeline.VerifyAsync(request);
// Assert
Assert.False(result.IsValid);
Assert.Equal(VerificationResult.Fail, result.Receipt.Result);
Assert.NotNull(result.Receipt.FailureReason);
Assert.Contains("not found", result.Receipt.FailureReason);
}
#endregion
#region Cancellation Tests
[Fact]
public async Task VerifyAsync_Cancelled_ReturnsPartialResults()
{
// Arrange
var bundleId = CreateTestBundleId();
var keyId = "test-key-id";
var cts = new CancellationTokenSource();
SetupValidProofBundle(bundleId, keyId);
// Setup DSSE verification to cancel
_dsseVerifierMock
.Setup(x => x.VerifyAsync(It.IsAny<DsseEnvelope>(), It.IsAny<CancellationToken>()))
.Returns(async (DsseEnvelope _, CancellationToken ct) =>
{
await cts.CancelAsync();
ct.ThrowIfCancellationRequested();
return new DsseVerificationResult { IsValid = true, KeyId = keyId };
});
var request = new VerificationPipelineRequest
{
ProofBundleId = bundleId,
VerifyRekor = false
};
// Act & Assert - should complete but show cancellation
// The actual behavior depends on implementation
var result = await _pipeline.VerifyAsync(request, cts.Token);
// Pipeline may handle cancellation gracefully
}
#endregion
#region Helper Methods
private static ProofBundleId CreateTestBundleId()
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString()));
return new ProofBundleId($"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}");
}
private void SetupValidProofBundle(ProofBundleId bundleId, string keyId, bool includeRekorEntry = true)
{
var bundle = new ProofBundle
{
Statements = new List<ProofStatement>
{
new ProofStatement
{
StatementId = "sha256:statement123",
PredicateType = "https://stella-ops.io/v1/evidence",
Predicate = new { test = "data" }
}
},
Envelopes = new List<DsseEnvelope>
{
new DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
Payload = Encoding.UTF8.GetBytes("{}"),
Signatures = new List<DsseSignature>
{
new DsseSignature { KeyId = keyId, Sig = new byte[64] }
}
}
},
RekorLogEntry = includeRekorEntry ? new RekorLogEntry
{
LogId = "test-log",
LogIndex = 12345,
InclusionProof = new InclusionProof
{
Hashes = new List<byte[]>(),
TreeSize = 100,
RootHash = new byte[32]
},
SignedTreeHead = new SignedTreeHead
{
TreeSize = 100,
RootHash = new byte[32],
Signature = new byte[64]
}
} : null
};
_proofStoreMock
.Setup(x => x.GetBundleAsync(bundleId, It.IsAny<CancellationToken>()))
.ReturnsAsync(bundle);
}
private void SetupProofBundleWithMismatchedId(ProofBundleId bundleId, string keyId)
{
// Create a bundle that will compute to a different ID
var bundle = new ProofBundle
{
Statements = new List<ProofStatement>
{
new ProofStatement
{
StatementId = "sha256:differentstatement",
PredicateType = "https://stella-ops.io/v1/evidence",
Predicate = new { different = "data" }
}
},
Envelopes = new List<DsseEnvelope>
{
new DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
Payload = Encoding.UTF8.GetBytes("{\"different\":\"payload\"}"),
Signatures = new List<DsseSignature>
{
new DsseSignature { KeyId = keyId, Sig = new byte[64] }
}
}
}
};
_proofStoreMock
.Setup(x => x.GetBundleAsync(bundleId, It.IsAny<CancellationToken>()))
.ReturnsAsync(bundle);
}
private void SetupValidDsseVerification(string keyId)
{
_dsseVerifierMock
.Setup(x => x.VerifyAsync(It.IsAny<DsseEnvelope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DsseVerificationResult { IsValid = true, KeyId = keyId });
}
private void SetupInvalidDsseVerification(string errorMessage)
{
_dsseVerifierMock
.Setup(x => x.VerifyAsync(It.IsAny<DsseEnvelope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DsseVerificationResult
{
IsValid = false,
KeyId = "unknown",
ErrorMessage = errorMessage
});
}
private void SetupValidRekorVerification()
{
_rekorVerifierMock
.Setup(x => x.VerifyInclusionAsync(
It.IsAny<string>(),
It.IsAny<long>(),
It.IsAny<InclusionProof>(),
It.IsAny<SignedTreeHead>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new RekorVerificationResult { IsValid = true });
}
private void SetupInvalidRekorVerification(string errorMessage)
{
_rekorVerifierMock
.Setup(x => x.VerifyInclusionAsync(
It.IsAny<string>(),
It.IsAny<long>(),
It.IsAny<InclusionProof>(),
It.IsAny<SignedTreeHead>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new RekorVerificationResult { IsValid = false, ErrorMessage = errorMessage });
}
private void SetupValidTrustAnchor(Guid anchorId, string keyId)
{
var anchor = new TrustAnchorInfo
{
AnchorId = anchorId,
AllowedKeyIds = new List<string> { keyId },
RevokedKeyIds = new List<string>()
};
_trustAnchorResolverMock
.Setup(x => x.FindAnchorForProofAsync(It.IsAny<ProofBundleId>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(anchor);
_trustAnchorResolverMock
.Setup(x => x.GetAnchorAsync(anchorId, It.IsAny<CancellationToken>()))
.ReturnsAsync(anchor);
}
private void SetupTrustAnchorWithoutKey(Guid anchorId, string keyId)
{
var anchor = new TrustAnchorInfo
{
AnchorId = anchorId,
AllowedKeyIds = new List<string> { "other-key-not-matching" },
RevokedKeyIds = new List<string>()
};
_trustAnchorResolverMock
.Setup(x => x.FindAnchorForProofAsync(It.IsAny<ProofBundleId>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(anchor);
}
#endregion
}

View File

@@ -286,6 +286,8 @@ internal static partial class CommandHandlers
} }
} }
var dssePath = (verifyDsse || verifyRekor) ? ResolveOfflineDssePath(bundleDir) : null;
var dsseVerified = false; var dsseVerified = false;
if (verifyDsse) if (verifyDsse)
{ {
@@ -304,7 +306,6 @@ internal static partial class CommandHandlers
return; return;
} }
var dssePath = ResolveOfflineDssePath(bundleDir);
if (dssePath is null) if (dssePath is null)
{ {
verificationLog.Add("dsse:missing"); verificationLog.Add("dsse:missing");
@@ -507,6 +508,44 @@ internal static partial class CommandHandlers
var rekorVerified = false; var rekorVerified = false;
if (verifyRekor) if (verifyRekor)
{ {
if (dssePath is null)
{
verificationLog.Add("rekor:missing-dsse");
var quarantineId = await TryQuarantineOfflineBundleAsync(
loggerFactory,
quarantineRoot,
effectiveTenant,
bundlePath,
manifestJson,
reasonCode: "REKOR_VERIFY_FAIL",
reasonMessage: "Rekor verification requires a DSSE statement file (statement.dsse.json).",
verificationLog,
cancellationToken).ConfigureAwait(false);
await WriteOfflineImportResultAsync(
emitJson,
new OfflineImportResultPayload(
Status: "failed",
ExitCode: OfflineExitCodes.RekorVerificationFailed,
TenantId: effectiveTenant,
BundlePath: bundlePath,
ManifestPath: manifestPath,
Version: manifest.Version,
Digest: $"sha256:{bundleDigest}",
DsseVerified: dsseVerified,
RekorVerified: false,
ActivatedAt: null,
WasForceActivated: false,
ForceActivateReason: null,
QuarantineId: quarantineId,
ReasonCode: "REKOR_VERIFY_FAIL",
ReasonMessage: "Rekor verification requires a DSSE statement file (statement.dsse.json)."),
cancellationToken).ConfigureAwait(false);
Environment.ExitCode = OfflineExitCodes.RekorVerificationFailed;
return;
}
var rekorPath = ResolveOfflineRekorReceiptPath(bundleDir); var rekorPath = ResolveOfflineRekorReceiptPath(bundleDir);
if (rekorPath is null) if (rekorPath is null)
{ {
@@ -546,20 +585,10 @@ internal static partial class CommandHandlers
return; return;
} }
var receiptJson = await File.ReadAllTextAsync(rekorPath, cancellationToken).ConfigureAwait(false); var rekorKeyPath = ResolveOfflineRekorPublicKeyPath(bundleDir);
var receipt = JsonSerializer.Deserialize<OfflineKitRekorReceiptDocument>(receiptJson, new JsonSerializerOptions(JsonSerializerDefaults.Web) if (rekorKeyPath is null)
{ {
PropertyNameCaseInsensitive = true verificationLog.Add("rekor:missing-public-key");
});
if (receipt is null ||
string.IsNullOrWhiteSpace(receipt.Uuid) ||
receipt.LogIndex < 0 ||
string.IsNullOrWhiteSpace(receipt.RootHash) ||
receipt.Hashes is not { Count: > 0 } ||
string.IsNullOrWhiteSpace(receipt.Checkpoint))
{
verificationLog.Add("rekor:invalid");
var quarantineId = await TryQuarantineOfflineBundleAsync( var quarantineId = await TryQuarantineOfflineBundleAsync(
loggerFactory, loggerFactory,
quarantineRoot, quarantineRoot,
@@ -567,7 +596,7 @@ internal static partial class CommandHandlers
bundlePath, bundlePath,
manifestJson, manifestJson,
reasonCode: "REKOR_VERIFY_FAIL", reasonCode: "REKOR_VERIFY_FAIL",
reasonMessage: "Rekor receipt is missing required fields.", reasonMessage: "Rekor public key not found in offline bundle (rekor-pub.pem).",
verificationLog, verificationLog,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
@@ -588,16 +617,26 @@ internal static partial class CommandHandlers
ForceActivateReason: null, ForceActivateReason: null,
QuarantineId: quarantineId, QuarantineId: quarantineId,
ReasonCode: "REKOR_VERIFY_FAIL", ReasonCode: "REKOR_VERIFY_FAIL",
ReasonMessage: "Rekor receipt is missing required fields."), ReasonMessage: "Rekor public key not found in offline bundle (rekor-pub.pem)."),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
Environment.ExitCode = OfflineExitCodes.RekorVerificationFailed; Environment.ExitCode = OfflineExitCodes.RekorVerificationFailed;
return; return;
} }
if (receipt.Checkpoint.IndexOf(receipt.RootHash, StringComparison.OrdinalIgnoreCase) < 0) var dsseBytes = await File.ReadAllBytesAsync(dssePath, cancellationToken).ConfigureAwait(false);
var dsseSha256 = SHA256.HashData(dsseBytes);
var verify = await RekorOfflineReceiptVerifier.VerifyAsync(
rekorPath,
dsseSha256,
rekorKeyPath,
cancellationToken)
.ConfigureAwait(false);
if (!verify.Verified)
{ {
verificationLog.Add("rekor:checkpoint-mismatch"); verificationLog.Add("rekor:verify-failed");
var quarantineId = await TryQuarantineOfflineBundleAsync( var quarantineId = await TryQuarantineOfflineBundleAsync(
loggerFactory, loggerFactory,
quarantineRoot, quarantineRoot,
@@ -605,7 +644,7 @@ internal static partial class CommandHandlers
bundlePath, bundlePath,
manifestJson, manifestJson,
reasonCode: "REKOR_VERIFY_FAIL", reasonCode: "REKOR_VERIFY_FAIL",
reasonMessage: "Rekor checkpoint does not reference receipt rootHash.", reasonMessage: verify.FailureReason ?? "Rekor verification failed.",
verificationLog, verificationLog,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
@@ -626,7 +665,7 @@ internal static partial class CommandHandlers
ForceActivateReason: null, ForceActivateReason: null,
QuarantineId: quarantineId, QuarantineId: quarantineId,
ReasonCode: "REKOR_VERIFY_FAIL", ReasonCode: "REKOR_VERIFY_FAIL",
ReasonMessage: "Rekor checkpoint does not reference receipt rootHash."), ReasonMessage: verify.FailureReason ?? "Rekor verification failed."),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
Environment.ExitCode = OfflineExitCodes.RekorVerificationFailed; Environment.ExitCode = OfflineExitCodes.RekorVerificationFailed;
@@ -635,8 +674,15 @@ internal static partial class CommandHandlers
rekorVerified = true; rekorVerified = true;
verificationLog.Add("rekor:ok"); verificationLog.Add("rekor:ok");
activity?.SetTag("stellaops.cli.offline.rekor_uuid", receipt.Uuid); if (!string.IsNullOrWhiteSpace(verify.RekorUuid))
activity?.SetTag("stellaops.cli.offline.rekor_log_index", receipt.LogIndex); {
activity?.SetTag("stellaops.cli.offline.rekor_uuid", verify.RekorUuid);
}
if (verify.LogIndex is not null)
{
activity?.SetTag("stellaops.cli.offline.rekor_log_index", verify.LogIndex.Value);
}
} }
BundleVersion incomingVersion; BundleVersion incomingVersion;
@@ -947,6 +993,25 @@ internal static partial class CommandHandlers
return candidates.FirstOrDefault(File.Exists); return candidates.FirstOrDefault(File.Exists);
} }
private static string? ResolveOfflineRekorPublicKeyPath(string bundleDirectory)
{
var candidates = new[]
{
Path.Combine(bundleDirectory, "rekor-pub.pem"),
Path.Combine(bundleDirectory, "rekor.pub"),
Path.Combine(bundleDirectory, "tlog-root.pub"),
Path.Combine(bundleDirectory, "tlog-root.pem"),
Path.Combine(bundleDirectory, "tlog", "rekor-pub.pem"),
Path.Combine(bundleDirectory, "tlog", "rekor.pub"),
Path.Combine(bundleDirectory, "keys", "tlog-root", "rekor-pub.pem"),
Path.Combine(bundleDirectory, "keys", "tlog-root", "rekor.pub"),
Path.Combine(bundleDirectory, "evidence", "keys", "tlog-root", "rekor-pub.pem"),
Path.Combine(bundleDirectory, "evidence", "keys", "tlog-root", "rekor.pub"),
};
return candidates.FirstOrDefault(File.Exists);
}
private static async Task<byte[]> LoadTrustRootPublicKeyAsync(string path, CancellationToken cancellationToken) private static async Task<byte[]> LoadTrustRootPublicKeyAsync(string path, CancellationToken cancellationToken)
{ {
var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);

View File

@@ -121,15 +121,58 @@ public sealed class OfflineCommandHandlersTests
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); }, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(dssePath, dsseJson, CancellationToken.None); await File.WriteAllTextAsync(dssePath, dsseJson, CancellationToken.None);
var rootHash = "deadbeef"; static byte[] HashLeaf(byte[] leafData)
{
var buffer = new byte[1 + leafData.Length];
buffer[0] = 0x00;
leafData.CopyTo(buffer, 1);
return SHA256.HashData(buffer);
}
static byte[] HashInterior(byte[] left, byte[] right)
{
var buffer = new byte[1 + left.Length + right.Length];
buffer[0] = 0x01;
left.CopyTo(buffer, 1);
right.CopyTo(buffer, 1 + left.Length);
return SHA256.HashData(buffer);
}
// Deterministic DSSE digest used as the Rekor leaf input.
var dsseBytes = await File.ReadAllBytesAsync(dssePath, CancellationToken.None);
var dsseSha256 = SHA256.HashData(dsseBytes);
// Build a minimal 2-leaf RFC6962 Merkle tree proof for logIndex=0.
var leaf0 = HashLeaf(dsseSha256);
var leaf1 = HashLeaf(SHA256.HashData(Encoding.UTF8.GetBytes("other-envelope")));
var rootHashBytes = HashInterior(leaf0, leaf1);
using var rekorKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var checkpointOrigin = "rekor.sigstore.dev - 2605736670972794746";
var checkpointTimestamp = "1700000000";
var checkpointBody = $"{checkpointOrigin}\n2\n{Convert.ToBase64String(rootHashBytes)}\n{checkpointTimestamp}\n";
var checkpointSig = rekorKey.SignData(Encoding.UTF8.GetBytes(checkpointBody), HashAlgorithmName.SHA256);
var rekorPublicKeyPath = Path.Combine(bundleDir, "rekor-pub.pem");
await File.WriteAllTextAsync(
rekorPublicKeyPath,
WrapPem("PUBLIC KEY", rekorKey.ExportSubjectPublicKeyInfo()),
CancellationToken.None);
var checkpointPath = Path.Combine(bundleDir, "checkpoint.sig");
await File.WriteAllTextAsync(
checkpointPath,
checkpointBody + $"sig {Convert.ToBase64String(checkpointSig)}\n",
CancellationToken.None);
var rekorPath = Path.Combine(bundleDir, "rekor-receipt.json"); var rekorPath = Path.Combine(bundleDir, "rekor-receipt.json");
var rekorJson = JsonSerializer.Serialize(new var rekorJson = JsonSerializer.Serialize(new
{ {
uuid = "rekor-test", uuid = "rekor-test",
logIndex = 42, logIndex = 0,
rootHash, rootHash = Convert.ToHexString(rootHashBytes).ToLowerInvariant(),
hashes = new[] { "hash-1" }, hashes = new[] { Convert.ToHexString(leaf1).ToLowerInvariant() },
checkpoint = $"checkpoint {rootHash}" checkpoint = "checkpoint.sig"
}, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }); }, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await File.WriteAllTextAsync(rekorPath, rekorJson, CancellationToken.None); await File.WriteAllTextAsync(rekorPath, rekorJson, CancellationToken.None);

View File

@@ -24,6 +24,20 @@
- `docs/modules/export-center/operations/kms-envelope-pattern.md` (for 37-002 encryption/KMS) - `docs/modules/export-center/operations/kms-envelope-pattern.md` (for 37-002 encryption/KMS)
- `docs/modules/export-center/operations/risk-bundle-provider-matrix.md` (for 69/70 risk bundle chain) - `docs/modules/export-center/operations/risk-bundle-provider-matrix.md` (for 69/70 risk bundle chain)
- Sprint file `docs/implplan/SPRINT_0164_0001_0001_exportcenter_iii.md` - Sprint file `docs/implplan/SPRINT_0164_0001_0001_exportcenter_iii.md`
- Offline triage bundle format: `docs/airgap/offline-bundle-format.md` (SPRINT_3603/3605)
## Offline Evidence Bundles & Cache (SPRINT_3603 / SPRINT_3605)
- Bundle format: `.stella.bundle.tgz` with DSSE-signed manifest and deterministic entry hashing (no external fetches required to verify).
- Core implementation (source of truth):
- `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/OfflineBundle/BundleManifest.cs`
- `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/OfflineBundle/BundlePredicate.cs`
- `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/OfflineBundle/OfflineBundlePackager.cs`
- Determinism requirements:
- All manifest entries and tarball paths must be sorted deterministically (ordinal string compare).
- Hash inputs must be canonical and stable; retrying packaging MUST yield identical bundle bytes when inputs are unchanged.
- Local evidence cache (offline-first, side-by-side with scan artefacts):
- `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/EvidenceCache/LocalEvidenceCacheService.cs`
- Cache manifests and enrichment queue must be deterministic and replay-safe.
## Working Agreements ## Working Agreements
- Enforce tenant scoping and RBAC on every API, worker fetch, and distribution path; no cross-tenant exports unless explicitly whitelisted and logged. - Enforce tenant scoping and RBAC on every API, worker fetch, and distribution path; no cross-tenant exports unless explicitly whitelisted and logged.

View File

@@ -20,6 +20,13 @@ Deliver the Export Center service described in Epic10. Provide reproducible,
## Required Reading ## Required Reading
- `docs/modules/export-center/architecture.md` - `docs/modules/export-center/architecture.md`
- `docs/modules/platform/architecture-overview.md` - `docs/modules/platform/architecture-overview.md`
- `docs/airgap/offline-bundle-format.md` (triage offline bundles)
## Contracts (Offline Triage Bundles)
- Offline triage bundles are `.stella.bundle.tgz` files with a DSSE-signed manifest and deterministic entry ordering.
- Source of truth code paths:
- `StellaOps.ExportCenter.Core/OfflineBundle/*` (bundle schema, predicate, packager)
- `StellaOps.ExportCenter.Core/EvidenceCache/*` (local evidence cache + enrichment queue)
## Working Agreement ## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work. - 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.

View File

@@ -0,0 +1,7 @@
# Export Center · Local Tasks
This file mirrors sprint work for the Export Center module.
| Task ID | Sprint | Status | Notes |
| --- | --- | --- | --- |
| `TRI-MASTER-0005` | `docs/implplan/SPRINT_3600_0001_0001_triage_unknowns_master.md` | DONE (2025-12-17) | Sync ExportCenter AGENTS with offline triage bundle (`.stella.bundle.tgz`) + local evidence cache contracts. |

Some files were not shown because too many files have changed in this diff Show More