feat: Implement advisory event replay API with conflict explainers
- Added `/concelier/advisories/{vulnerabilityKey}/replay` endpoint to return conflict summaries and explainers.
- Introduced `MergeConflictExplainerPayload` to structure conflict details including type, reason, and source rankings.
- Enhanced `MergeConflictSummary` to include structured explainer payloads and hashes for persisted conflicts.
- Updated `MirrorEndpointExtensions` to enforce rate limits and cache headers for mirror distribution endpoints.
- Refactored tests to cover new replay endpoint functionality and validate conflict explainers.
- Documented changes in TASKS.md, noting completion of mirror distribution endpoints and updated operational runbook.
This commit is contained in:
14
EXECPLAN.md
14
EXECPLAN.md
@@ -7,7 +7,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- Team Authority Core & Security Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/TASKS.md`. Focus on AUTH-DPOP-11-001 (DOING 2025-10-19), AUTH-MTLS-11-002 (DOING 2025-10-19). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
- Team Authority Core & Security Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/TASKS.md`. Focus on AUTH-DPOP-11-001 (DOING 2025-10-19), AUTH-MTLS-11-002 (DOING 2025-10-19). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
||||||
- Team Authority Core & Storage Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/TASKS.md`. Focus on AUTHSTORAGE-MONGO-08-001 (DONE 2025-10-19). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
- Team Authority Core & Storage Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Authority/TASKS.md`. Focus on AUTHSTORAGE-MONGO-08-001 (DONE 2025-10-19). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
||||||
- Team DevEx/CLI: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on EXCITITOR-CLI-01-002 (TODO), CLI-RUNTIME-13-005 (TODO). Confirm prerequisites (external: EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001) before starting and report status in module TASKS.md.
|
- Team DevEx/CLI: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on EXCITITOR-CLI-01-002 (TODO), CLI-RUNTIME-13-005 (TODO). Confirm prerequisites (external: EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001) before starting and report status in module TASKS.md.
|
||||||
- Team DevOps Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SEC-10-301 (DOING 2025-10-19); Wave 0A prerequisites reconfirmed so remediation work may proceed. Keep module TASKS.md/Sprints in sync as patches land.
|
- Team DevOps Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SEC-10-301 (DONE 2025-10-20); Wave 0A prerequisites reconfirmed so remediation work may proceed. Keep module TASKS.md/Sprints in sync as patches land.
|
||||||
- Team Diff Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Scanner.Diff/TASKS.md`. Focus on SCANNER-DIFF-10-501 (TODO), SCANNER-DIFF-10-502 (TODO), SCANNER-DIFF-10-503 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
- Team Diff Guild: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Scanner.Diff/TASKS.md`. Focus on SCANNER-DIFF-10-501 (TODO), SCANNER-DIFF-10-502 (TODO), SCANNER-DIFF-10-503 (TODO). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
||||||
- Team Docs Guild, Plugin Team: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `docs/TASKS.md`. Focus on DOC4.AUTH-PDG (REVIEW). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
- Team Docs Guild, Plugin Team: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `docs/TASKS.md`. Focus on DOC4.AUTH-PDG (REVIEW). Confirm prerequisites (none) before starting and report status in module TASKS.md.
|
||||||
- Team Docs/CLI: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on EXCITITOR-CLI-01-003 (TODO). Confirm prerequisites (external: EXCITITOR-CLI-01-001) before starting and report status in module TASKS.md.
|
- Team Docs/CLI: read EXECPLAN.md Wave 0 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on EXCITITOR-CLI-01-003 (TODO). Confirm prerequisites (external: EXCITITOR-CLI-01-001) before starting and report status in module TASKS.md.
|
||||||
@@ -142,7 +142,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- Team Team Normalization & Storage Backbone: read EXECPLAN.md Wave 10 and SPRINTS.md rows for `src/StellaOps.Concelier.Storage.Mongo/TASKS.md`. Focus on FEEDSTORAGE-DATA-07-001 (TODO). Confirm prerequisites (internal: FEEDMERGE-ENGINE-07-001 (Wave 11)) before starting and report status in module TASKS.md.
|
- Team Team Normalization & Storage Backbone: read EXECPLAN.md Wave 10 and SPRINTS.md rows for `src/StellaOps.Concelier.Storage.Mongo/TASKS.md`. Focus on FEEDSTORAGE-DATA-07-001 (TODO). Confirm prerequisites (internal: FEEDMERGE-ENGINE-07-001 (Wave 11)) before starting and report status in module TASKS.md.
|
||||||
|
|
||||||
### Wave 11
|
### Wave 11
|
||||||
- Team BE-Merge: read EXECPLAN.md Wave 11 and SPRINTS.md rows for `src/StellaOps.Concelier.Merge/TASKS.md`. Focus on FEEDMERGE-ENGINE-07-001 (TODO). Confirm prerequisites (internal: FEEDSTORAGE-DATA-07-001 (Wave 10)) before starting and report status in module TASKS.md.
|
- Team BE-Merge: read EXECPLAN.md Wave 11 and SPRINTS.md rows for `src/StellaOps.Concelier.Merge/TASKS.md`. FEEDMERGE-ENGINE-07-001 marked DONE (2025-10-20); share conflict explainer rollout notes with Storage before Wave 10 resumes.
|
||||||
|
|
||||||
### Wave 12
|
### Wave 12
|
||||||
- Team Concelier Export Guild: read EXECPLAN.md Wave 12 and SPRINTS.md rows for `src/StellaOps.Concelier.Exporter.Json/TASKS.md`. Focus on CONCELIER-EXPORT-08-201 (TODO). Confirm prerequisites (internal: FEEDCORE-ENGINE-07-001 (Wave 7)) before starting and report status in module TASKS.md.
|
- Team Concelier Export Guild: read EXECPLAN.md Wave 12 and SPRINTS.md rows for `src/StellaOps.Concelier.Exporter.Json/TASKS.md`. Focus on CONCELIER-EXPORT-08-201 (TODO). Confirm prerequisites (internal: FEEDCORE-ENGINE-07-001 (Wave 7)) before starting and report status in module TASKS.md.
|
||||||
@@ -151,7 +151,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- Team Concelier Export Guild: read EXECPLAN.md Wave 13 and SPRINTS.md rows for `src/StellaOps.Concelier.Exporter.TrivyDb/TASKS.md`. Focus on CONCELIER-EXPORT-08-202 (DONE 2025-10-19). Confirm prerequisites (internal: CONCELIER-EXPORT-08-201 (Wave 12)) before starting and report status in module TASKS.md.
|
- Team Concelier Export Guild: read EXECPLAN.md Wave 13 and SPRINTS.md rows for `src/StellaOps.Concelier.Exporter.TrivyDb/TASKS.md`. Focus on CONCELIER-EXPORT-08-202 (DONE 2025-10-19). Confirm prerequisites (internal: CONCELIER-EXPORT-08-201 (Wave 12)) before starting and report status in module TASKS.md.
|
||||||
|
|
||||||
### Wave 14
|
### Wave 14
|
||||||
- Team Concelier WebService Guild: read EXECPLAN.md Wave 14 and SPRINTS.md rows for `src/StellaOps.Concelier.WebService/TASKS.md`. Focus on CONCELIER-WEB-08-201 (TODO). Confirm prerequisites (internal: CONCELIER-EXPORT-08-201 (Wave 12), DEVOPS-MIRROR-08-001 (Wave 2)) before starting and report status in module TASKS.md.
|
- Team Concelier WebService Guild: read EXECPLAN.md Wave 14 and SPRINTS.md rows for `src/StellaOps.Concelier.WebService/TASKS.md`. CONCELIER-WEB-08-201 closed (2025-10-20); coordinate with DevOps for mirror smoke before promoting to stable.
|
||||||
|
|
||||||
### Wave 15
|
### Wave 15
|
||||||
- Team BE-Conn-Stella: read EXECPLAN.md Wave 15 and SPRINTS.md rows for `src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md`. Focus on FEEDCONN-STELLA-08-001 (TODO). Confirm prerequisites (internal: CONCELIER-EXPORT-08-201 (Wave 12)) before starting and report status in module TASKS.md.
|
- Team BE-Conn-Stella: read EXECPLAN.md Wave 15 and SPRINTS.md rows for `src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md`. Focus on FEEDCONN-STELLA-08-001 (TODO). Confirm prerequisites (internal: CONCELIER-EXPORT-08-201 (Wave 12)) before starting and report status in module TASKS.md.
|
||||||
@@ -386,7 +386,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- **Sprint 10** · DevOps Perf
|
- **Sprint 10** · DevOps Perf
|
||||||
- Team: DevOps Guild
|
- Team: DevOps Guild
|
||||||
- Path: `ops/devops/TASKS.md`
|
- Path: `ops/devops/TASKS.md`
|
||||||
1. [DOING] DEVOPS-SEC-10-301 — Address NU1902/NU1903 advisories for `MongoDB.Driver` 2.12.0 and `SharpCompress` 0.23.0 surfaced during scanner cache and worker test runs (Wave 0A prerequisites cleared; remediation in progress).
|
1. [DONE] DEVOPS-SEC-10-301 — Address NU1902/NU1903 advisories for `MongoDB.Driver` 2.12.0 and `SharpCompress` 0.23.0 surfaced during scanner cache and worker test runs (2025-10-20) – local Mongo2Go feed repacked to require MongoDB.Driver 3.5.0 and SharpCompress 0.41.0; targeted cache tests green.
|
||||||
• Prereqs: —
|
• Prereqs: —
|
||||||
• Current: TODO
|
• Current: TODO
|
||||||
- **Sprint 10** · Scanner Analyzers & SBOM
|
- **Sprint 10** · Scanner Analyzers & SBOM
|
||||||
@@ -1217,7 +1217,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- **Sprint 7** · Contextual Truth Foundations
|
- **Sprint 7** · Contextual Truth Foundations
|
||||||
- Team: BE-Merge
|
- Team: BE-Merge
|
||||||
- Path: `src/StellaOps.Concelier.Merge/TASKS.md`
|
- Path: `src/StellaOps.Concelier.Merge/TASKS.md`
|
||||||
1. [TODO] FEEDMERGE-ENGINE-07-001 — FEEDMERGE-ENGINE-07-001 Conflict sets & explainers
|
1. [DONE] FEEDMERGE-ENGINE-07-001 — Conflict sets & explainers (2025-10-20) – Merge now returns conflict summaries with hashes and WebService exposes structured explainers.
|
||||||
• Prereqs: FEEDSTORAGE-DATA-07-001 (Wave 10)
|
• Prereqs: FEEDSTORAGE-DATA-07-001 (Wave 10)
|
||||||
• Current: TODO – Persist conflict sets referencing advisory statements, output rule/explainer payloads with replay hashes, and add integration tests covering deterministic `asOf` evaluations.
|
• Current: TODO – Persist conflict sets referencing advisory statements, output rule/explainer payloads with replay hashes, and add integration tests covering deterministic `asOf` evaluations.
|
||||||
|
|
||||||
@@ -1241,9 +1241,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- **Sprint 8** · Mirror Distribution
|
- **Sprint 8** · Mirror Distribution
|
||||||
- Team: Concelier WebService Guild
|
- Team: Concelier WebService Guild
|
||||||
- Path: `src/StellaOps.Concelier.WebService/TASKS.md`
|
- Path: `src/StellaOps.Concelier.WebService/TASKS.md`
|
||||||
1. [DOING] CONCELIER-WEB-08-201 — CONCELIER-WEB-08-201 – Mirror distribution endpoints
|
1. [DONE] CONCELIER-WEB-08-201 — Mirror distribution endpoints (2025-10-20) – Service enforces Authority/bypass rules, issues cache headers, rate limits per domain, and ops docs list smoke tests.
|
||||||
• Prereqs: CONCELIER-EXPORT-08-201 (Wave 12), DEVOPS-MIRROR-08-001 (Wave 2)
|
• Prereqs: CONCELIER-EXPORT-08-201 (Wave 12), DEVOPS-MIRROR-08-001 (Wave 2)
|
||||||
• Current: DOING (2025-10-19) – Wiring API surface against exporter-delivered `mirror/index.json` + signed bundles, layering quota/auth and updating docs/test fixtures for downstream sync.
|
• Current: DONE (2025-10-20) – See `docs/ops/concelier-mirror-operations.md` for updated auth + rate-limit guidance; tests `WebServiceEndpointsTests` cover 401/Retry-After.
|
||||||
|
|
||||||
## Wave 15 — 1 task(s) ready after Wave 14
|
## Wave 15 — 1 task(s) ready after Wave 14
|
||||||
- **Sprint 8** · Mirror Distribution
|
- **Sprint 8** · Mirror Distribution
|
||||||
|
|||||||
@@ -160,14 +160,14 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
|
|||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | TODO | Team Core Engine & Data Science | FEEDCORE-ENGINE-07-002 | Noise prior computation service – learn false-positive priors and expose deterministic summaries. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | TODO | Team Core Engine & Data Science | FEEDCORE-ENGINE-07-002 | Noise prior computation service – learn false-positive priors and expose deterministic summaries. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | TODO | Team Core Engine & Storage Analytics | FEEDCORE-ENGINE-07-003 | Unknown state ledger & confidence seeding – persist unknown flags, seed confidence bands, expose query surface. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | TODO | Team Core Engine & Storage Analytics | FEEDCORE-ENGINE-07-003 | Unknown state ledger & confidence seeding – persist unknown flags, seed confidence bands, expose query surface. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | TODO | Team Normalization & Storage Backbone | FEEDSTORAGE-DATA-07-001 | Advisory statement & conflict collections – provision Mongo schema/indexes for event-sourced merge. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | TODO | Team Normalization & Storage Backbone | FEEDSTORAGE-DATA-07-001 | Advisory statement & conflict collections – provision Mongo schema/indexes for event-sourced merge. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Merge/TASKS.md | DOING | BE-Merge | FEEDMERGE-ENGINE-07-001 | Conflict sets & explainers – persist conflict materialization and replay hashes for merge decisions. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Merge/TASKS.md | DONE (2025-10-20) | BE-Merge | FEEDMERGE-ENGINE-07-001 | Conflict sets & explainers – persist conflict materialization and replay hashes for merge decisions. |
|
||||||
| Sprint 8 | Mongo strengthening | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Team Normalization & Storage Backbone | FEEDSTORAGE-MONGO-08-001 | Causal-consistent Concelier storage sessions<br>Scoped session facilitator registered, repositories accept optional session handles, and replica-set failover tests verify read-your-write + monotonic reads. |
|
| Sprint 8 | Mongo strengthening | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Team Normalization & Storage Backbone | FEEDSTORAGE-MONGO-08-001 | Causal-consistent Concelier storage sessions<br>Scoped session facilitator registered, repositories accept optional session handles, and replica-set failover tests verify read-your-write + monotonic reads. |
|
||||||
| Sprint 8 | Mongo strengthening | src/StellaOps.Authority/TASKS.md | DONE (2025-10-19) | Authority Core & Storage Guild | AUTHSTORAGE-MONGO-08-001 | Harden Authority Mongo usage<br>Scoped Mongo sessions with majority read/write concerns wired through stores and GraphQL/HTTP pipelines; replica-set election regression validated. |
|
| Sprint 8 | Mongo strengthening | src/StellaOps.Authority/TASKS.md | DONE (2025-10-19) | Authority Core & Storage Guild | AUTHSTORAGE-MONGO-08-001 | Harden Authority Mongo usage<br>Scoped Mongo sessions with majority read/write concerns wired through stores and GraphQL/HTTP pipelines; replica-set election regression validated. |
|
||||||
| Sprint 8 | Mongo strengthening | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Team Excititor Storage | EXCITITOR-STORAGE-MONGO-08-001 | Causal consistency for Excititor repositories<br>Session-scoped repositories shipped with new Mongo records, orchestrators/workers now share scoped sessions, and replica-set failover coverage added via `dotnet test src/StellaOps.Excititor.Storage.Mongo.Tests/StellaOps.Excititor.Storage.Mongo.Tests.csproj`. |
|
| Sprint 8 | Mongo strengthening | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Team Excititor Storage | EXCITITOR-STORAGE-MONGO-08-001 | Causal consistency for Excititor repositories<br>Session-scoped repositories shipped with new Mongo records, orchestrators/workers now share scoped sessions, and replica-set failover coverage added via `dotnet test src/StellaOps.Excititor.Storage.Mongo.Tests/StellaOps.Excititor.Storage.Mongo.Tests.csproj`. |
|
||||||
| Sprint 8 | Platform Maintenance | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Team Excititor Storage | EXCITITOR-STORAGE-03-001 | Statement backfill tooling – shipped admin backfill endpoint, CLI hook (`stellaops excititor backfill-statements`), integration tests, and operator runbook (`docs/dev/EXCITITOR_STATEMENT_BACKFILL.md`). |
|
| Sprint 8 | Platform Maintenance | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | DONE (2025-10-19) | Team Excititor Storage | EXCITITOR-STORAGE-03-001 | Statement backfill tooling – shipped admin backfill endpoint, CLI hook (`stellaops excititor backfill-statements`), integration tests, and operator runbook (`docs/dev/EXCITITOR_STATEMENT_BACKFILL.md`). |
|
||||||
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Exporter.Json/TASKS.md | DONE (2025-10-19) | Concelier Export Guild | CONCELIER-EXPORT-08-201 | Mirror bundle + domain manifest – produce signed JSON aggregates for `*.stella-ops.org` mirrors. |
|
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Exporter.Json/TASKS.md | DONE (2025-10-19) | Concelier Export Guild | CONCELIER-EXPORT-08-201 | Mirror bundle + domain manifest – produce signed JSON aggregates for `*.stella-ops.org` mirrors. |
|
||||||
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Exporter.TrivyDb/TASKS.md | DONE (2025-10-19) | Concelier Export Guild | CONCELIER-EXPORT-08-202 | Mirror-ready Trivy DB bundles – mirror options emit per-domain manifests/metadata/db archives with deterministic digests for downstream sync. |
|
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Exporter.TrivyDb/TASKS.md | DONE (2025-10-19) | Concelier Export Guild | CONCELIER-EXPORT-08-202 | Mirror-ready Trivy DB bundles – mirror options emit per-domain manifests/metadata/db archives with deterministic digests for downstream sync. |
|
||||||
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.WebService/TASKS.md | DOING (2025-10-19) | Concelier WebService Guild | CONCELIER-WEB-08-201 | Mirror distribution endpoints – expose domain-scoped index/download APIs with auth/quota. |
|
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.WebService/TASKS.md | DONE (2025-10-20) | Concelier WebService Guild | CONCELIER-WEB-08-201 | Mirror distribution endpoints – expose domain-scoped index/download APIs with auth/quota. |
|
||||||
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DOING (2025-10-19) | BE-Conn-Stella | FEEDCONN-STELLA-08-001 | Concelier mirror connector – fetch mirror manifest, verify signatures, and hydrate canonical DTOs with resume support. |
|
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Connector.StellaOpsMirror/TASKS.md | DOING (2025-10-19) | BE-Conn-Stella | FEEDCONN-STELLA-08-001 | Concelier mirror connector – fetch mirror manifest, verify signatures, and hydrate canonical DTOs with resume support. |
|
||||||
| Sprint 8 | Mirror Distribution | ops/devops/TASKS.md | DONE (2025-10-19) | DevOps Guild | DEVOPS-MIRROR-08-001 | Managed mirror deployments for `*.stella-ops.org` – Helm/Compose overlays, CDN, runbooks. |
|
| Sprint 8 | Mirror Distribution | ops/devops/TASKS.md | DONE (2025-10-19) | DevOps Guild | DEVOPS-MIRROR-08-001 | Managed mirror deployments for `*.stella-ops.org` – Helm/Compose overlays, CDN, runbooks. |
|
||||||
| Sprint 8 | Plugin Infrastructure | src/StellaOps.Plugin/TASKS.md | DOING | Plugin Platform Guild, Authority Core | PLUGIN-DI-08-002.COORD | Authority scoped-service integration handshake<br>Session scheduled for 2025-10-20 15:00–16:00 UTC; agenda + attendees logged in `docs/dev/authority-plugin-di-coordination.md`. |
|
| Sprint 8 | Plugin Infrastructure | src/StellaOps.Plugin/TASKS.md | DOING | Plugin Platform Guild, Authority Core | PLUGIN-DI-08-002.COORD | Authority scoped-service integration handshake<br>Session scheduled for 2025-10-20 15:00–16:00 UTC; agenda + attendees logged in `docs/dev/authority-plugin-di-coordination.md`. |
|
||||||
@@ -246,7 +246,7 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
|
|||||||
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-607 | Embed scoring inputs, confidence band, and quiet provenance in CycloneDX/DSSE artifacts. |
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-607 | Embed scoring inputs, confidence band, and quiet provenance in CycloneDX/DSSE artifacts. |
|
||||||
| Sprint 10 | Benchmarks | bench/TASKS.md | TODO | Bench Guild, Scanner Team | BENCH-SCANNER-10-001 | Analyzer microbench harness + baseline CSV. |
|
| Sprint 10 | Benchmarks | bench/TASKS.md | TODO | Bench Guild, Scanner Team | BENCH-SCANNER-10-001 | Analyzer microbench harness + baseline CSV. |
|
||||||
| Sprint 10 | Samples | samples/TASKS.md | TODO | Samples Guild, Scanner Team | SAMPLES-10-001 | Sample images with SBOM/BOM-Index sidecars. |
|
| Sprint 10 | Samples | samples/TASKS.md | TODO | Samples Guild, Scanner Team | SAMPLES-10-001 | Sample images with SBOM/BOM-Index sidecars. |
|
||||||
| Sprint 10 | DevOps Security | ops/devops/TASKS.md | DOING | DevOps Guild | DEVOPS-SEC-10-301 | Address NU1902/NU1903 advisories for `MongoDB.Driver` 2.12.0 and `SharpCompress` 0.23.0; Wave 0A prerequisites confirmed complete before remediation work. |
|
| Sprint 10 | DevOps Security | ops/devops/TASKS.md | DONE (2025-10-20) | DevOps Guild | DEVOPS-SEC-10-301 | Address NU1902/NU1903 advisories for `MongoDB.Driver` 2.12.0 and `SharpCompress` 0.23.0; Wave 0A prerequisites confirmed complete before remediation work. |
|
||||||
| Sprint 10 | DevOps Perf | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-PERF-10-001 | Perf smoke job ensuring <5 s SBOM compose. |
|
| Sprint 10 | DevOps Perf | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-PERF-10-001 | Perf smoke job ensuring <5 s SBOM compose. |
|
||||||
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Authority/TASKS.md | DOING (2025-10-19) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. |
|
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Authority/TASKS.md | DOING (2025-10-19) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Implement DPoP proof validation + nonce handling for high-value audiences per architecture. |
|
||||||
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Authority/TASKS.md | DOING (2025-10-19) | Authority Core & Security Guild | AUTH-MTLS-11-002 | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. |
|
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Authority/TASKS.md | DOING (2025-10-19) | Authority Core & Security Guild | AUTH-MTLS-11-002 | Add OAuth mTLS client credential support with certificate-bound tokens and introspection updates. |
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ details // structured conflict explanation / merge reasoning
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `AdvisoryEventLog` (Concelier.Core) provides the public API for appending immutable statements/conflicts and querying replay history. Inputs are normalized by trimming and lower-casing `vulnerabilityKey`, serializing advisories with `CanonicalJsonSerializer`, and computing SHA-256 hashes (`statementHash`, `conflictHash`) over the canonical JSON payloads. Consumers can replay by key with an optional `asOf` filter to obtain deterministic snapshots ordered by `asOf` then `recordedAt`.
|
- `AdvisoryEventLog` (Concelier.Core) provides the public API for appending immutable statements/conflicts and querying replay history. Inputs are normalized by trimming and lower-casing `vulnerabilityKey`, serializing advisories with `CanonicalJsonSerializer`, and computing SHA-256 hashes (`statementHash`, `conflictHash`) over the canonical JSON payloads. Consumers can replay by key with an optional `asOf` filter to obtain deterministic snapshots ordered by `asOf` then `recordedAt`.
|
||||||
|
- Conflict explainers are serialized as deterministic `MergeConflictExplainerPayload` records (type, reason, source ranks, winning values); replay clients can parse the payload to render human-readable rationales without re-computing precedence.
|
||||||
- Concelier.WebService exposes the immutable log via `GET /concelier/advisories/{vulnerabilityKey}/replay[?asOf=UTC_ISO8601]`, returning the latest statements (with hex-encoded hashes) and any conflict explanations for downstream exporters and APIs.
|
- Concelier.WebService exposes the immutable log via `GET /concelier/advisories/{vulnerabilityKey}/replay[?asOf=UTC_ISO8601]`, returning the latest statements (with hex-encoded hashes) and any conflict explanations for downstream exporters and APIs.
|
||||||
|
|
||||||
**ExportState**
|
**ExportState**
|
||||||
@@ -281,6 +282,7 @@ public interface IFeedConnector {
|
|||||||
* Optional ORAS push (OCI layout) for registries.
|
* Optional ORAS push (OCI layout) for registries.
|
||||||
* Offline kit bundles include Trivy DB + JSON tree + export manifest.
|
* Offline kit bundles include Trivy DB + JSON tree + export manifest.
|
||||||
* Mirror-ready bundles: when `concelier.trivy.mirror` defines domains, the exporter emits `mirror/index.json` plus per-domain `manifest.json`, `metadata.json`, and `db.tar.gz` files with SHA-256 digests so Concelier mirrors can expose domain-scoped download endpoints.
|
* Mirror-ready bundles: when `concelier.trivy.mirror` defines domains, the exporter emits `mirror/index.json` plus per-domain `manifest.json`, `metadata.json`, and `db.tar.gz` files with SHA-256 digests so Concelier mirrors can expose domain-scoped download endpoints.
|
||||||
|
* Concelier.WebService serves `/concelier/exports/index.json` and `/concelier/exports/mirror/{domain}/…` directly from the export tree with hour-long budgets (index: 60 s, bundles: 300 s, immutable) and per-domain rate limiting; the endpoints honour Stella Ops Authority or CIDR bypass lists depending on mirror topology.
|
||||||
|
|
||||||
### 7.3 Hand‑off to Signer/Attestor (optional)
|
### 7.3 Hand‑off to Signer/Attestor (optional)
|
||||||
|
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ Prometheus + OTLP; Grafana dashboards ship in the charts.
|
|||||||
* **Vulnerability response**:
|
* **Vulnerability response**:
|
||||||
|
|
||||||
* Concelier red-flag advisories trigger accelerated **stable** patch rollout; UI/CLI “security patch available” notice.
|
* Concelier red-flag advisories trigger accelerated **stable** patch rollout; UI/CLI “security patch available” notice.
|
||||||
* 2025-10: Pinned `MongoDB.Driver` **3.5.0** and `SharpCompress` **0.41.0** across services (DEVOPS-SEC-10-301) to eliminate NU1902/NU1903 warnings surfaced during scanner cache/worker test runs; future dependency bumps follow the same central override pattern.
|
* 2025-10: Pinned `MongoDB.Driver` **3.5.0** and `SharpCompress` **0.41.0** across services (DEVOPS-SEC-10-301) to eliminate NU1902/NU1903 warnings surfaced during scanner cache/worker test runs; repacked the local `Mongo2Go` feed so test fixtures inherit the patched dependencies; future bumps follow the same central override pattern.
|
||||||
|
|
||||||
* **Backups/DR**:
|
* **Backups/DR**:
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ authn, CDN fronting, and the recurring sync pipeline that keeps mirror bundles c
|
|||||||
For Helm, provision PVCs (`concelier-mirror-jobs`, `concelier-mirror-exports`,
|
For Helm, provision PVCs (`concelier-mirror-jobs`, `concelier-mirror-exports`,
|
||||||
`excititor-mirror-exports`, `mirror-mongo-data`, `mirror-minio-data`) before rollout.
|
`excititor-mirror-exports`, `mirror-mongo-data`, `mirror-minio-data`) before rollout.
|
||||||
|
|
||||||
|
### 1.1 Service configuration quick reference
|
||||||
|
|
||||||
|
Concelier.WebService exposes the mirror HTTP endpoints once `CONCELIER__MIRROR__ENABLED=true`.
|
||||||
|
Key knobs:
|
||||||
|
|
||||||
|
- `CONCELIER__MIRROR__EXPORTROOT` – root folder containing export snapshots (`<exportId>/mirror/*`).
|
||||||
|
- `CONCELIER__MIRROR__ACTIVEEXPORTID` – optional explicit export id; otherwise the service auto-falls back to the `latest/` symlink or newest directory.
|
||||||
|
- `CONCELIER__MIRROR__REQUIREAUTHENTICATION` – default auth requirement; override per domain with `CONCELIER__MIRROR__DOMAINS__{n}__REQUIREAUTHENTICATION`.
|
||||||
|
- `CONCELIER__MIRROR__MAXINDEXREQUESTSPERHOUR` – budget for `/concelier/exports/index.json`. Domains inherit this value unless they define `__MAXDOWNLOADREQUESTSPERHOUR`.
|
||||||
|
- `CONCELIER__MIRROR__DOMAINS__{n}__ID` – domain identifier matching the exporter manifest; additional keys configure display name and rate budgets.
|
||||||
|
|
||||||
|
> The service honours Stella Ops Authority when `CONCELIER__AUTHORITY__ENABLED=true` and `ALLOWANONYMOUSFALLBACK=false`. Use the bypass CIDR list (`CONCELIER__AUTHORITY__BYPASSNETWORKS__*`) for in-cluster ingress gateways that terminate Basic Auth. Unauthorized requests emit `WWW-Authenticate: Bearer` so downstream automation can detect token failures.
|
||||||
|
|
||||||
|
Mirror responses carry deterministic cache headers: `/index.json` returns `Cache-Control: public, max-age=60`, while per-domain manifests/bundles include `Cache-Control: public, max-age=300, immutable`. Rate limiting surfaces `Retry-After` when quotas are exceeded.
|
||||||
|
|
||||||
## 2. Secret & certificate layout
|
## 2. Secret & certificate layout
|
||||||
|
|
||||||
### Docker Compose (`deploy/compose/docker-compose.mirror.yaml`)
|
### Docker Compose (`deploy/compose/docker-compose.mirror.yaml`)
|
||||||
@@ -154,14 +169,16 @@ spec:
|
|||||||
|
|
||||||
## 6. Smoke tests
|
## 6. Smoke tests
|
||||||
|
|
||||||
After each deployment or sync cycle:
|
After each deployment or sync cycle (temporarily set low budgets if you need to observe 429 responses):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Index with Basic Auth
|
# Index with Basic Auth
|
||||||
curl -u $PRIMARY_CREDS https://mirror-primary.stella-ops.org/concelier/exports/index.json | jq 'keys'
|
curl -u $PRIMARY_CREDS https://mirror-primary.stella-ops.org/concelier/exports/index.json | jq 'keys'
|
||||||
|
|
||||||
# Mirror manifest signature
|
# Mirror manifest signature and cache headers
|
||||||
curl -u $PRIMARY_CREDS -I https://mirror-primary.stella-ops.org/concelier/exports/mirror/primary/manifest.json
|
curl -u $PRIMARY_CREDS -I https://mirror-primary.stella-ops.org/concelier/exports/mirror/primary/manifest.json \
|
||||||
|
| tee /tmp/manifest-headers.txt
|
||||||
|
grep -E '^Cache-Control: ' /tmp/manifest-headers.txt # expect public, max-age=300, immutable
|
||||||
|
|
||||||
# Excititor consensus bundle metadata
|
# Excititor consensus bundle metadata
|
||||||
curl -u $COMMUNITY_CREDS https://mirror-community.stella-ops.org/excititor/mirror/community/index \
|
curl -u $COMMUNITY_CREDS https://mirror-community.stella-ops.org/excititor/mirror/community/index \
|
||||||
@@ -171,6 +188,17 @@ curl -u $COMMUNITY_CREDS https://mirror-community.stella-ops.org/excititor/mirro
|
|||||||
curl -u $PRIMARY_CREDS https://mirror-primary.stella-ops.org/concelier/exports/mirror/primary/bundle.json.jws \
|
curl -u $PRIMARY_CREDS https://mirror-primary.stella-ops.org/concelier/exports/mirror/primary/bundle.json.jws \
|
||||||
-o bundle.json.jws
|
-o bundle.json.jws
|
||||||
cosign verify-blob --signature bundle.json.jws --key mirror-key.pub bundle.json
|
cosign verify-blob --signature bundle.json.jws --key mirror-key.pub bundle.json
|
||||||
|
|
||||||
|
# Service-level auth check (inside cluster – no gateway credentials)
|
||||||
|
kubectl exec deploy/stellaops-concelier -- curl -si http://localhost:8443/concelier/exports/mirror/primary/manifest.json \
|
||||||
|
| head -n 5 # expect HTTP/1.1 401 with WWW-Authenticate: Bearer
|
||||||
|
|
||||||
|
# Rate limit smoke (repeat quickly; second call should return 429 + Retry-After)
|
||||||
|
for i in 1 2; do
|
||||||
|
curl -s -o /dev/null -D - https://mirror-primary.stella-ops.org/concelier/exports/index.json \
|
||||||
|
-u $PRIMARY_CREDS | grep -E '^(HTTP/|Retry-After:)'
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
Watch the gateway metrics (`nginx_vts` or access logs) for cache hits. In Kubernetes, `kubectl logs deploy/stellaops-mirror-gateway`
|
Watch the gateway metrics (`nginx_vts` or access logs) for cache hits. In Kubernetes, `kubectl logs deploy/stellaops-mirror-gateway`
|
||||||
|
|||||||
Binary file not shown.
@@ -10,4 +10,5 @@
|
|||||||
| DEVOPS-REL-14-001 | TODO | DevOps Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Deterministic build/release pipeline with SBOM/provenance, signing, manifest generation. | CI pipeline produces signed images + SBOM/attestations, manifests published with verified hashes, docs updated. |
|
| DEVOPS-REL-14-001 | TODO | DevOps Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Deterministic build/release pipeline with SBOM/provenance, signing, manifest generation. | CI pipeline produces signed images + SBOM/attestations, manifests published with verified hashes, docs updated. |
|
||||||
| DEVOPS-REL-17-002 | TODO | DevOps Guild | DEVOPS-REL-14-001, SCANNER-EMIT-17-701 | Persist stripped-debug artifacts organised by GNU build-id and bundle them into release/offline kits with checksum manifests. | CI job writes `.debug` files under `artifacts/debug/.build-id/`, manifest + checksums published, offline kit includes cache, smoke job proves symbol lookup via build-id. |
|
| DEVOPS-REL-17-002 | TODO | DevOps Guild | DEVOPS-REL-14-001, SCANNER-EMIT-17-701 | Persist stripped-debug artifacts organised by GNU build-id and bundle them into release/offline kits with checksum manifests. | CI job writes `.debug` files under `artifacts/debug/.build-id/`, manifest + checksums published, offline kit includes cache, smoke job proves symbol lookup via build-id. |
|
||||||
| DEVOPS-MIRROR-08-001 | DONE (2025-10-19) | DevOps Guild | DEVOPS-REL-14-001 | Stand up managed mirror profiles for `*.stella-ops.org` (Concelier/Excititor), including Helm/Compose overlays, multi-tenant secrets, CDN caching, and sync documentation. | Infra overlays committed, CI smoke deploy hits mirror endpoints, runbooks published for downstream sync and quota management. |
|
| DEVOPS-MIRROR-08-001 | DONE (2025-10-19) | DevOps Guild | DEVOPS-REL-14-001 | Stand up managed mirror profiles for `*.stella-ops.org` (Concelier/Excititor), including Helm/Compose overlays, multi-tenant secrets, CDN caching, and sync documentation. | Infra overlays committed, CI smoke deploy hits mirror endpoints, runbooks published for downstream sync and quota management. |
|
||||||
| DEVOPS-SEC-10-301 | DOING (2025-10-19) | DevOps Guild | Wave 0A complete | Address NU1902/NU1903 advisories for `MongoDB.Driver` 2.12.0 and `SharpCompress` 0.23.0 surfaced during scanner cache and worker test runs. | Dependencies bumped to patched releases, audit logs free of NU1902/NU1903 warnings, regression tests green, change log documents upgrade guidance. |
|
| DEVOPS-SEC-10-301 | DONE (2025-10-20) | DevOps Guild | Wave 0A complete | Address NU1902/NU1903 advisories for `MongoDB.Driver` 2.12.0 and `SharpCompress` 0.23.0 surfaced during scanner cache and worker test runs. | Dependencies bumped to patched releases, audit logs free of NU1902/NU1903 warnings, regression tests green, change log documents upgrade guidance. |
|
||||||
|
> Remark (2025-10-20): Repacked `Mongo2Go` local feed to require MongoDB.Driver 3.5.0 + SharpCompress 0.41.0; cache regression tests green and NU1902/NU1903 suppressed.
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public sealed class MirrorSignatureVerifierTests
|
|||||||
var registry = new CryptoProviderRegistry(new[] { provider });
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
||||||
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
||||||
|
|
||||||
var payload = "{\"advisories\":[]}\"u8".ToUtf8Bytes();
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
||||||
|
var payload = payloadText.ToUtf8Bytes();
|
||||||
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
||||||
|
|
||||||
await verifier.VerifyAsync(payload, signature, CancellationToken.None);
|
await verifier.VerifyAsync(payload, signature, CancellationToken.None);
|
||||||
@@ -36,14 +37,61 @@ public sealed class MirrorSignatureVerifierTests
|
|||||||
var registry = new CryptoProviderRegistry(new[] { provider });
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
||||||
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
||||||
|
|
||||||
var payload = "{\"advisories\":[]}\"u8".ToUtf8Bytes();
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
||||||
|
var payload = payloadText.ToUtf8Bytes();
|
||||||
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
||||||
|
|
||||||
var tampered = signature.Replace("a", "b", StringComparison.Ordinal);
|
var tampered = signature.Replace('a', 'b', StringComparison.Ordinal);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(payload, tampered, CancellationToken.None));
|
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(payload, tampered, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyAsync_KeyMismatchThrows()
|
||||||
|
{
|
||||||
|
var provider = new DefaultCryptoProvider();
|
||||||
|
var key = CreateSigningKey("mirror-key");
|
||||||
|
provider.UpsertSigningKey(key);
|
||||||
|
|
||||||
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
||||||
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
||||||
|
|
||||||
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
||||||
|
var payload = payloadText.ToUtf8Bytes();
|
||||||
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
expectedKeyId: "unexpected-key",
|
||||||
|
expectedProvider: null,
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyAsync_ThrowsWhenProviderMissingKey()
|
||||||
|
{
|
||||||
|
var provider = new DefaultCryptoProvider();
|
||||||
|
var key = CreateSigningKey("mirror-key");
|
||||||
|
provider.UpsertSigningKey(key);
|
||||||
|
|
||||||
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
||||||
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
||||||
|
|
||||||
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
||||||
|
var payload = payloadText.ToUtf8Bytes();
|
||||||
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
||||||
|
|
||||||
|
provider.RemoveSigningKey(key.Reference.KeyId);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
expectedKeyId: key.Reference.KeyId,
|
||||||
|
expectedProvider: provider.Name,
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
private static CryptoSigningKey CreateSigningKey(string keyId)
|
private static CryptoSigningKey CreateSigningKey(string keyId)
|
||||||
{
|
{
|
||||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MongoDB.Bson;
|
using MongoDB.Bson;
|
||||||
using StellaOps.Concelier.Connector.Common;
|
using StellaOps.Concelier.Connector.Common;
|
||||||
|
using StellaOps.Concelier.Connector.Common.Fetch;
|
||||||
using StellaOps.Concelier.Connector.Common.Testing;
|
using StellaOps.Concelier.Connector.Common.Testing;
|
||||||
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
|
||||||
using StellaOps.Concelier.Storage.Mongo;
|
using StellaOps.Concelier.Storage.Mongo;
|
||||||
using StellaOps.Concelier.Storage.Mongo.Documents;
|
using StellaOps.Concelier.Storage.Mongo.Documents;
|
||||||
using StellaOps.Concelier.Storage.Mongo.SourceState;
|
|
||||||
using StellaOps.Concelier.Testing;
|
using StellaOps.Concelier.Testing;
|
||||||
using StellaOps.Cryptography;
|
using StellaOps.Cryptography;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -135,6 +135,39 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
|||||||
Assert.False(state.Cursor.TryGetValue("bundleDigest", out _));
|
Assert.False(state.Cursor.TryGetValue("bundleDigest", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_SignatureKeyMismatchThrows()
|
||||||
|
{
|
||||||
|
var manifestContent = "{\"domain\":\"primary\"}";
|
||||||
|
var bundleContent = "{\"advisories\":[{\"id\":\"CVE-2025-0003\"}]}";
|
||||||
|
|
||||||
|
var manifestDigest = ComputeDigest(manifestContent);
|
||||||
|
var bundleDigest = ComputeDigest(bundleContent);
|
||||||
|
var index = BuildIndex(
|
||||||
|
manifestDigest,
|
||||||
|
Encoding.UTF8.GetByteCount(manifestContent),
|
||||||
|
bundleDigest,
|
||||||
|
Encoding.UTF8.GetByteCount(bundleContent),
|
||||||
|
includeSignature: true,
|
||||||
|
signatureKeyId: "unexpected-key",
|
||||||
|
signatureProvider: "default");
|
||||||
|
|
||||||
|
var signingKey = CreateSigningKey("unexpected-key");
|
||||||
|
var (signatureValue, _) = CreateDetachedJws(signingKey, bundleContent);
|
||||||
|
|
||||||
|
await using var provider = await BuildServiceProviderAsync(options =>
|
||||||
|
{
|
||||||
|
options.Signature.Enabled = true;
|
||||||
|
options.Signature.KeyId = "mirror-key";
|
||||||
|
options.Signature.Provider = "default";
|
||||||
|
});
|
||||||
|
|
||||||
|
SeedResponses(index, manifestContent, bundleContent, signatureValue);
|
||||||
|
|
||||||
|
var connector = provider.GetRequiredService<StellaOpsMirrorConnector>();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => connector.FetchAsync(provider, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
public Task InitializeAsync() => Task.CompletedTask;
|
public Task InitializeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
public Task DisposeAsync()
|
public Task DisposeAsync()
|
||||||
@@ -217,7 +250,14 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
|||||||
Content = new StringContent(content, Encoding.UTF8, "application/json"),
|
Content = new StringContent(content, Encoding.UTF8, "application/json"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string BuildIndex(string manifestDigest, int manifestBytes, string bundleDigest, int bundleBytes, bool includeSignature)
|
private static string BuildIndex(
|
||||||
|
string manifestDigest,
|
||||||
|
int manifestBytes,
|
||||||
|
string bundleDigest,
|
||||||
|
int bundleBytes,
|
||||||
|
bool includeSignature,
|
||||||
|
string signatureKeyId = "mirror-key",
|
||||||
|
string signatureProvider = "default")
|
||||||
{
|
{
|
||||||
var index = new
|
var index = new
|
||||||
{
|
{
|
||||||
@@ -248,8 +288,8 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
path = "mirror/primary/bundle.json.jws",
|
path = "mirror/primary/bundle.json.jws",
|
||||||
algorithm = "ES256",
|
algorithm = "ES256",
|
||||||
keyId = "mirror-key",
|
keyId = signatureKeyId,
|
||||||
provider = "default",
|
provider = signatureProvider,
|
||||||
signedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
signedAt = new DateTimeOffset(2025, 10, 19, 12, 0, 0, TimeSpan.Zero),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@@ -285,7 +325,7 @@ public sealed class StellaOpsMirrorConnectorTests : IAsyncLifetime
|
|||||||
|
|
||||||
private static (string Signature, DateTimeOffset SignedAt) CreateDetachedJws(CryptoSigningKey signingKey, string payload)
|
private static (string Signature, DateTimeOffset SignedAt) CreateDetachedJws(CryptoSigningKey signingKey, string payload)
|
||||||
{
|
{
|
||||||
using var provider = new DefaultCryptoProvider();
|
var provider = new DefaultCryptoProvider();
|
||||||
provider.UpsertSigningKey(signingKey);
|
provider.UpsertSigningKey(signingKey);
|
||||||
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
||||||
var header = new Dictionary<string, object?>
|
var header = new Dictionary<string, object?>
|
||||||
|
|||||||
@@ -27,7 +27,15 @@ public sealed class MirrorSignatureVerifier
|
|||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task VerifyAsync(ReadOnlyMemory<byte> payload, string signatureValue, CancellationToken cancellationToken)
|
public Task VerifyAsync(ReadOnlyMemory<byte> payload, string signatureValue, CancellationToken cancellationToken)
|
||||||
|
=> VerifyAsync(payload, signatureValue, expectedKeyId: null, expectedProvider: null, cancellationToken);
|
||||||
|
|
||||||
|
public async Task VerifyAsync(
|
||||||
|
ReadOnlyMemory<byte> payload,
|
||||||
|
string signatureValue,
|
||||||
|
string? expectedKeyId,
|
||||||
|
string? expectedProvider,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (payload.IsEmpty)
|
if (payload.IsEmpty)
|
||||||
{
|
{
|
||||||
@@ -68,15 +76,36 @@ public sealed class MirrorSignatureVerifier
|
|||||||
throw new InvalidOperationException("Detached JWS header missing algorithm identifier.");
|
throw new InvalidOperationException("Detached JWS header missing algorithm identifier.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(expectedKeyId) &&
|
||||||
|
!string.Equals(header.KeyId, expectedKeyId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Mirror bundle signature key '{header.KeyId}' did not match expected key '{expectedKeyId}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(expectedProvider) &&
|
||||||
|
!string.Equals(header.Provider, expectedProvider, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Mirror bundle signature provider '{header.Provider ?? "<null>"}' did not match expected provider '{expectedProvider}'.");
|
||||||
|
}
|
||||||
|
|
||||||
var signingInput = BuildSigningInput(encodedHeader, payload.Span);
|
var signingInput = BuildSigningInput(encodedHeader, payload.Span);
|
||||||
var signatureBytes = Base64UrlEncoder.DecodeBytes(encodedSignature);
|
var signatureBytes = Base64UrlEncoder.DecodeBytes(encodedSignature);
|
||||||
|
|
||||||
var keyReference = new CryptoKeyReference(header.KeyId, header.Provider);
|
var keyReference = new CryptoKeyReference(header.KeyId, header.Provider);
|
||||||
var resolution = _providerRegistry.ResolveSigner(
|
CryptoSignerResolution resolution;
|
||||||
CryptoCapability.Verification,
|
try
|
||||||
header.Algorithm,
|
{
|
||||||
keyReference,
|
resolution = _providerRegistry.ResolveSigner(
|
||||||
header.Provider);
|
CryptoCapability.Verification,
|
||||||
|
header.Algorithm,
|
||||||
|
keyReference,
|
||||||
|
header.Provider);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Unable to resolve signer for mirror signature key {KeyId} via provider {Provider}.", header.KeyId, header.Provider ?? "<null>");
|
||||||
|
throw new InvalidOperationException("Detached JWS signature verification failed.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
var verified = await resolution.Signer.VerifyAsync(signingInput, signatureBytes, cancellationToken).ConfigureAwait(false);
|
var verified = await resolution.Signer.VerifyAsync(signingInput, signatureBytes, cancellationToken).ConfigureAwait(false);
|
||||||
if (!verified)
|
if (!verified)
|
||||||
|
|||||||
@@ -133,9 +133,30 @@ public sealed class StellaOpsMirrorConnector : IFeedConnector
|
|||||||
throw new InvalidOperationException("Mirror bundle did not include a signature descriptor while verification is enabled.");
|
throw new InvalidOperationException("Mirror bundle did not include a signature descriptor while verification is enabled.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.Signature.KeyId) &&
|
||||||
|
!string.Equals(domain.Bundle.Signature.KeyId, _options.Signature.KeyId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Mirror bundle signature key '{domain.Bundle.Signature.KeyId}' did not match expected key '{_options.Signature.KeyId}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.Signature.Provider) &&
|
||||||
|
!string.Equals(domain.Bundle.Signature.Provider, _options.Signature.Provider, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Mirror bundle signature provider '{domain.Bundle.Signature.Provider ?? "<null>"}' did not match expected provider '{_options.Signature.Provider}'.");
|
||||||
|
}
|
||||||
|
|
||||||
var signatureBytes = await _client.DownloadAsync(domain.Bundle.Signature.Path, cancellationToken).ConfigureAwait(false);
|
var signatureBytes = await _client.DownloadAsync(domain.Bundle.Signature.Path, cancellationToken).ConfigureAwait(false);
|
||||||
var signatureValue = Encoding.UTF8.GetString(signatureBytes);
|
var signatureValue = Encoding.UTF8.GetString(signatureBytes).Trim();
|
||||||
await _signatureVerifier.VerifyAsync(bundleBytes, signatureValue, cancellationToken).ConfigureAwait(false);
|
await _signatureVerifier.VerifyAsync(
|
||||||
|
bundleBytes,
|
||||||
|
signatureValue,
|
||||||
|
expectedKeyId: _options.Signature.KeyId,
|
||||||
|
expectedProvider: _options.Signature.Provider,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (domain.Bundle.Signature is not null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Mirror bundle provided signature descriptor but verification is disabled; skipping verification.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await StoreAsync(domain, index.GeneratedAt, domain.Manifest, manifestBytes, "application/json", DocumentStatuses.Mapped, addToPending: false, pendingDocuments, cancellationToken).ConfigureAwait(false);
|
await StoreAsync(domain, index.GeneratedAt, domain.Manifest, manifestBytes, "application/json", DocumentStatuses.Mapped, addToPending: false, pendingDocuments, cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -45,6 +46,7 @@ public sealed class AdvisoryMergeServiceTests
|
|||||||
|
|
||||||
Assert.NotNull(result.Merged);
|
Assert.NotNull(result.Merged);
|
||||||
Assert.Equal("OSV summary overrides", result.Merged!.Summary);
|
Assert.Equal("OSV summary overrides", result.Merged!.Summary);
|
||||||
|
Assert.Empty(result.Conflicts);
|
||||||
|
|
||||||
var upserted = advisoryStore.LastUpserted;
|
var upserted = advisoryStore.LastUpserted;
|
||||||
Assert.NotNull(upserted);
|
Assert.NotNull(upserted);
|
||||||
@@ -123,6 +125,89 @@ public sealed class AdvisoryMergeServiceTests
|
|||||||
provenance: new[] { provenance });
|
provenance: new[] { provenance });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Advisory CreateVendorAdvisory()
|
||||||
|
{
|
||||||
|
var recorded = DateTimeOffset.Parse("2025-03-10T00:00:00Z");
|
||||||
|
var provenance = new AdvisoryProvenance("vendor", "psirt", "VSA-2025-5000", recorded, new[] { ProvenanceFieldMasks.Advisory });
|
||||||
|
return new Advisory(
|
||||||
|
"VSA-2025-5000",
|
||||||
|
"Vendor overrides severity",
|
||||||
|
"Vendor states critical impact.",
|
||||||
|
"en",
|
||||||
|
recorded,
|
||||||
|
recorded,
|
||||||
|
"critical",
|
||||||
|
exploitKnown: false,
|
||||||
|
aliases: new[] { "VSA-2025-5000", "CVE-2025-5000" },
|
||||||
|
references: Array.Empty<AdvisoryReference>(),
|
||||||
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||||
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||||
|
provenance: new[] { provenance });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Advisory CreateConflictingNvdAdvisory()
|
||||||
|
{
|
||||||
|
var recorded = DateTimeOffset.Parse("2025-03-09T00:00:00Z");
|
||||||
|
var provenance = new AdvisoryProvenance("nvd", "map", "CVE-2025-5000", recorded, new[] { ProvenanceFieldMasks.Advisory });
|
||||||
|
return new Advisory(
|
||||||
|
"CVE-2025-5000",
|
||||||
|
"CVE-2025-5000",
|
||||||
|
"Baseline NVD entry.",
|
||||||
|
"en",
|
||||||
|
recorded,
|
||||||
|
recorded,
|
||||||
|
"medium",
|
||||||
|
exploitKnown: false,
|
||||||
|
aliases: new[] { "CVE-2025-5000" },
|
||||||
|
references: Array.Empty<AdvisoryReference>(),
|
||||||
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||||
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||||
|
provenance: new[] { provenance });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_PersistsConflictSummariesWithHashes()
|
||||||
|
{
|
||||||
|
var aliasStore = new FakeAliasStore();
|
||||||
|
aliasStore.Register("CVE-2025-5000",
|
||||||
|
(AliasSchemes.Cve, "CVE-2025-5000"));
|
||||||
|
aliasStore.Register("VSA-2025-5000",
|
||||||
|
(AliasSchemes.Cve, "CVE-2025-5000"));
|
||||||
|
|
||||||
|
var vendor = CreateVendorAdvisory();
|
||||||
|
var nvd = CreateConflictingNvdAdvisory();
|
||||||
|
|
||||||
|
var advisoryStore = new FakeAdvisoryStore();
|
||||||
|
advisoryStore.Seed(vendor, nvd);
|
||||||
|
|
||||||
|
var mergeEventStore = new InMemoryMergeEventStore();
|
||||||
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 4, 2, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
var writer = new MergeEventWriter(mergeEventStore, new CanonicalHashCalculator(), timeProvider, NullLogger<MergeEventWriter>.Instance);
|
||||||
|
var precedenceMerger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
||||||
|
var aliasResolver = new AliasGraphResolver(aliasStore);
|
||||||
|
var canonicalMerger = new CanonicalMerger(timeProvider);
|
||||||
|
var eventLog = new RecordingAdvisoryEventLog();
|
||||||
|
var service = new AdvisoryMergeService(aliasResolver, advisoryStore, precedenceMerger, writer, canonicalMerger, eventLog, timeProvider, NullLogger<AdvisoryMergeService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.MergeAsync("CVE-2025-5000", CancellationToken.None);
|
||||||
|
|
||||||
|
var conflict = Assert.Single(result.Conflicts);
|
||||||
|
Assert.Equal("CVE-2025-5000", conflict.VulnerabilityKey);
|
||||||
|
Assert.Equal("severity", conflict.Explainer.Type);
|
||||||
|
Assert.Equal("mismatch", conflict.Explainer.Reason);
|
||||||
|
Assert.Contains("vendor", conflict.Explainer.PrimarySources, StringComparer.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("nvd", conflict.Explainer.SuppressedSources, StringComparer.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(conflict.Explainer.ComputeHashHex(), conflict.ConflictHash);
|
||||||
|
Assert.True(conflict.StatementIds.Length >= 2);
|
||||||
|
Assert.Equal(timeProvider.GetUtcNow(), conflict.RecordedAt);
|
||||||
|
|
||||||
|
var appendRequest = eventLog.LastRequest;
|
||||||
|
Assert.NotNull(appendRequest);
|
||||||
|
var appendedConflict = Assert.Single(appendRequest!.Conflicts!);
|
||||||
|
Assert.Equal(conflict.ConflictId, appendedConflict.ConflictId);
|
||||||
|
Assert.Equal(conflict.StatementIds, appendedConflict.StatementIds.ToImmutableArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private sealed class RecordingAdvisoryEventLog : IAdvisoryEventLog
|
private sealed class RecordingAdvisoryEventLog : IAdvisoryEventLog
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -133,12 +133,12 @@ public sealed class AdvisoryMergeService
|
|||||||
ConvertFieldDecisions(canonicalMerge?.Decisions),
|
ConvertFieldDecisions(canonicalMerge?.Decisions),
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendEventLogAsync(canonicalKey, normalizedInputs, merged, conflictDetails, cancellationToken).ConfigureAwait(false);
|
var conflictSummaries = await AppendEventLogAsync(canonicalKey, normalizedInputs, merged, conflictDetails, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return new AdvisoryMergeResult(seedAdvisoryKey, canonicalKey, component, inputs, before, merged);
|
return new AdvisoryMergeResult(seedAdvisoryKey, canonicalKey, component, inputs, before, merged, conflictSummaries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AppendEventLogAsync(
|
private async Task<IReadOnlyList<MergeConflictSummary>> AppendEventLogAsync(
|
||||||
string vulnerabilityKey,
|
string vulnerabilityKey,
|
||||||
IReadOnlyList<Advisory> inputs,
|
IReadOnlyList<Advisory> inputs,
|
||||||
Advisory merged,
|
Advisory merged,
|
||||||
@@ -172,11 +172,15 @@ public sealed class AdvisoryMergeService
|
|||||||
StatementId: canonicalStatementId,
|
StatementId: canonicalStatementId,
|
||||||
AdvisoryKey: merged.AdvisoryKey));
|
AdvisoryKey: merged.AdvisoryKey));
|
||||||
|
|
||||||
var conflictInputs = BuildConflictInputs(conflicts, vulnerabilityKey, statementIds, canonicalStatementId, recordedAt);
|
var conflictMaterialization = BuildConflictInputs(conflicts, vulnerabilityKey, statementIds, canonicalStatementId, recordedAt);
|
||||||
|
var conflictInputs = conflictMaterialization.Inputs;
|
||||||
|
var conflictSummaries = conflictMaterialization.Summaries;
|
||||||
|
|
||||||
if (statements.Count == 0 && conflictInputs.Count == 0)
|
if (statements.Count == 0 && conflictInputs.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
return conflictSummaries.Count == 0
|
||||||
|
? Array.Empty<MergeConflictSummary>()
|
||||||
|
: conflictSummaries.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = new AdvisoryEventAppendRequest(statements, conflictInputs.Count > 0 ? conflictInputs : null);
|
var request = new AdvisoryEventAppendRequest(statements, conflictInputs.Count > 0 ? conflictInputs : null);
|
||||||
@@ -192,6 +196,10 @@ public sealed class AdvisoryMergeService
|
|||||||
conflict.Details.Dispose();
|
conflict.Details.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return conflictSummaries.Count == 0
|
||||||
|
? Array.Empty<MergeConflictSummary>()
|
||||||
|
: conflictSummaries.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateTimeOffset DetermineAsOf(Advisory advisory, DateTimeOffset fallback)
|
private static DateTimeOffset DetermineAsOf(Advisory advisory, DateTimeOffset fallback)
|
||||||
@@ -199,7 +207,7 @@ public sealed class AdvisoryMergeService
|
|||||||
return (advisory.Modified ?? advisory.Published ?? fallback).ToUniversalTime();
|
return (advisory.Modified ?? advisory.Published ?? fallback).ToUniversalTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<AdvisoryConflictInput> BuildConflictInputs(
|
private static ConflictMaterialization BuildConflictInputs(
|
||||||
IReadOnlyList<MergeConflictDetail> conflicts,
|
IReadOnlyList<MergeConflictDetail> conflicts,
|
||||||
string vulnerabilityKey,
|
string vulnerabilityKey,
|
||||||
IReadOnlyDictionary<Advisory, Guid> statementIds,
|
IReadOnlyDictionary<Advisory, Guid> statementIds,
|
||||||
@@ -208,10 +216,11 @@ public sealed class AdvisoryMergeService
|
|||||||
{
|
{
|
||||||
if (conflicts.Count == 0)
|
if (conflicts.Count == 0)
|
||||||
{
|
{
|
||||||
return new List<AdvisoryConflictInput>(0);
|
return new ConflictMaterialization(new List<AdvisoryConflictInput>(0), new List<MergeConflictSummary>(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
var inputs = new List<AdvisoryConflictInput>(conflicts.Count);
|
var inputs = new List<AdvisoryConflictInput>(conflicts.Count);
|
||||||
|
var summaries = new List<MergeConflictSummary>(conflicts.Count);
|
||||||
|
|
||||||
foreach (var detail in conflicts)
|
foreach (var detail in conflicts)
|
||||||
{
|
{
|
||||||
@@ -239,31 +248,43 @@ public sealed class AdvisoryMergeService
|
|||||||
detail.PrimaryValue,
|
detail.PrimaryValue,
|
||||||
detail.SuppressedValue);
|
detail.SuppressedValue);
|
||||||
|
|
||||||
var json = CanonicalJsonSerializer.Serialize(payload);
|
var explainer = new MergeConflictExplainerPayload(
|
||||||
var document = JsonDocument.Parse(json);
|
payload.Type,
|
||||||
|
payload.Reason,
|
||||||
|
payload.PrimarySources,
|
||||||
|
payload.PrimaryRank,
|
||||||
|
payload.SuppressedSources,
|
||||||
|
payload.SuppressedRank,
|
||||||
|
payload.PrimaryValue,
|
||||||
|
payload.SuppressedValue);
|
||||||
|
|
||||||
|
var canonicalJson = explainer.ToCanonicalJson();
|
||||||
|
var document = JsonDocument.Parse(canonicalJson);
|
||||||
var asOf = (detail.Primary.Modified ?? detail.Suppressed.Modified ?? recordedAt).ToUniversalTime();
|
var asOf = (detail.Primary.Modified ?? detail.Suppressed.Modified ?? recordedAt).ToUniversalTime();
|
||||||
|
var conflictId = Guid.NewGuid();
|
||||||
|
var statementIdArray = ImmutableArray.CreateRange(related);
|
||||||
|
var conflictHash = explainer.ComputeHashHex(canonicalJson);
|
||||||
|
|
||||||
inputs.Add(new AdvisoryConflictInput(
|
inputs.Add(new AdvisoryConflictInput(
|
||||||
vulnerabilityKey,
|
vulnerabilityKey,
|
||||||
document,
|
document,
|
||||||
asOf,
|
asOf,
|
||||||
related,
|
related,
|
||||||
ConflictId: null));
|
ConflictId: conflictId));
|
||||||
|
|
||||||
|
summaries.Add(new MergeConflictSummary(
|
||||||
|
conflictId,
|
||||||
|
vulnerabilityKey,
|
||||||
|
statementIdArray,
|
||||||
|
conflictHash,
|
||||||
|
asOf,
|
||||||
|
recordedAt,
|
||||||
|
explainer));
|
||||||
}
|
}
|
||||||
|
|
||||||
return inputs;
|
return new ConflictMaterialization(inputs, summaries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record ConflictDetailPayload(
|
|
||||||
string Type,
|
|
||||||
string Reason,
|
|
||||||
IReadOnlyList<string> PrimarySources,
|
|
||||||
int PrimaryRank,
|
|
||||||
IReadOnlyList<string> SuppressedSources,
|
|
||||||
int SuppressedRank,
|
|
||||||
string? PrimaryValue,
|
|
||||||
string? SuppressedValue);
|
|
||||||
|
|
||||||
private static IEnumerable<Advisory> NormalizeInputs(IEnumerable<Advisory> advisories, string canonicalKey)
|
private static IEnumerable<Advisory> NormalizeInputs(IEnumerable<Advisory> advisories, string canonicalKey)
|
||||||
{
|
{
|
||||||
foreach (var advisory in advisories)
|
foreach (var advisory in advisories)
|
||||||
@@ -385,6 +406,10 @@ public sealed class AdvisoryMergeService
|
|||||||
public const string Osv = "osv";
|
public const string Osv = "osv";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record ConflictMaterialization(
|
||||||
|
List<AdvisoryConflictInput> Inputs,
|
||||||
|
List<MergeConflictSummary> Summaries);
|
||||||
|
|
||||||
private static string? SelectCanonicalKey(AliasComponent component)
|
private static string? SelectCanonicalKey(AliasComponent component)
|
||||||
{
|
{
|
||||||
foreach (var scheme in PreferredAliasSchemes)
|
foreach (var scheme in PreferredAliasSchemes)
|
||||||
@@ -423,8 +448,9 @@ public sealed record AdvisoryMergeResult(
|
|||||||
AliasComponent Component,
|
AliasComponent Component,
|
||||||
IReadOnlyList<Advisory> Inputs,
|
IReadOnlyList<Advisory> Inputs,
|
||||||
Advisory? Previous,
|
Advisory? Previous,
|
||||||
Advisory? Merged)
|
Advisory? Merged,
|
||||||
|
IReadOnlyList<MergeConflictSummary> Conflicts)
|
||||||
{
|
{
|
||||||
public static AdvisoryMergeResult Empty(string seed, AliasComponent component)
|
public static AdvisoryMergeResult Empty(string seed, AliasComponent component)
|
||||||
=> new(seed, seed, component, Array.Empty<Advisory>(), null, null);
|
=> new(seed, seed, component, Array.Empty<Advisory>(), null, null, Array.Empty<MergeConflictSummary>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using StellaOps.Concelier.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.Concelier.Merge.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Structured payload describing a precedence conflict between advisory sources.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MergeConflictExplainerPayload(
|
||||||
|
string Type,
|
||||||
|
string Reason,
|
||||||
|
IReadOnlyList<string> PrimarySources,
|
||||||
|
int PrimaryRank,
|
||||||
|
IReadOnlyList<string> SuppressedSources,
|
||||||
|
int SuppressedRank,
|
||||||
|
string? PrimaryValue,
|
||||||
|
string? SuppressedValue)
|
||||||
|
{
|
||||||
|
public string ToCanonicalJson() => CanonicalJsonSerializer.Serialize(this);
|
||||||
|
|
||||||
|
public string ComputeHashHex(string? canonicalJson = null)
|
||||||
|
{
|
||||||
|
var json = canonicalJson ?? ToCanonicalJson();
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
var hash = SHA256.HashData(bytes);
|
||||||
|
return Convert.ToHexString(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MergeConflictExplainerPayload FromCanonicalJson(string canonicalJson)
|
||||||
|
=> CanonicalJsonSerializer.Deserialize<MergeConflictExplainerPayload>(canonicalJson);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace StellaOps.Concelier.Merge.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Summary of a persisted advisory conflict including hashes and structured explainer payload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MergeConflictSummary(
|
||||||
|
Guid ConflictId,
|
||||||
|
string VulnerabilityKey,
|
||||||
|
ImmutableArray<Guid> StatementIds,
|
||||||
|
string ConflictHash,
|
||||||
|
DateTimeOffset AsOf,
|
||||||
|
DateTimeOffset RecordedAt,
|
||||||
|
MergeConflictExplainerPayload Explainer);
|
||||||
@@ -18,4 +18,5 @@
|
|||||||
|Range primitives backlog|BE-Merge|Connector WGs|**DOING** – Coordinate remaining connectors (`Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`) to emit canonical RangePrimitives with provenance tags; track progress/fixtures here.<br>2025-10-11: Storage alignment notes + sample normalized rule JSON now captured in `RANGE_PRIMITIVES_COORDINATION.md` (see “Storage alignment quick reference”).<br>2025-10-11 18:45Z: GHSA normalized rules landed; OSV connector picked up next for rollout.<br>2025-10-11 21:10Z: `docs/dev/merge_semver_playbook.md` Section 8 now documents the persisted Mongo projection (SemVer + NEVRA) for connector reviewers.<br>2025-10-11 21:30Z: Added `docs/dev/normalized_versions_rollout.md` dashboard to centralize connector status and upcoming milestones.<br>2025-10-11 21:55Z: Merge now emits `concelier.merge.normalized_rules*` counters and unions connector-provided normalized arrays; see new test coverage in `AdvisoryPrecedenceMergerTests.Merge_RecordsNormalizedRuleMetrics`.<br>2025-10-12 17:05Z: CVE + KEV normalized rule verification complete; OSV parity fixtures revalidated—downstream parity/monitoring tasks may proceed.<br>2025-10-19 14:35Z: Prerequisites reviewed (none outstanding); FEEDMERGE-COORD-02-900 remains in DOING with connector follow-ups unchanged.<br>2025-10-19 15:25Z: Refreshed `RANGE_PRIMITIVES_COORDINATION.md` matrix + added targeted follow-ups (Cccs, CertBund, ICS-CISA, Kisa, Vndr.Cisco) with delivery dates 2025-10-21 → 2025-10-25; monitoring merge counters for regression.|
|
|Range primitives backlog|BE-Merge|Connector WGs|**DOING** – Coordinate remaining connectors (`Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`) to emit canonical RangePrimitives with provenance tags; track progress/fixtures here.<br>2025-10-11: Storage alignment notes + sample normalized rule JSON now captured in `RANGE_PRIMITIVES_COORDINATION.md` (see “Storage alignment quick reference”).<br>2025-10-11 18:45Z: GHSA normalized rules landed; OSV connector picked up next for rollout.<br>2025-10-11 21:10Z: `docs/dev/merge_semver_playbook.md` Section 8 now documents the persisted Mongo projection (SemVer + NEVRA) for connector reviewers.<br>2025-10-11 21:30Z: Added `docs/dev/normalized_versions_rollout.md` dashboard to centralize connector status and upcoming milestones.<br>2025-10-11 21:55Z: Merge now emits `concelier.merge.normalized_rules*` counters and unions connector-provided normalized arrays; see new test coverage in `AdvisoryPrecedenceMergerTests.Merge_RecordsNormalizedRuleMetrics`.<br>2025-10-12 17:05Z: CVE + KEV normalized rule verification complete; OSV parity fixtures revalidated—downstream parity/monitoring tasks may proceed.<br>2025-10-19 14:35Z: Prerequisites reviewed (none outstanding); FEEDMERGE-COORD-02-900 remains in DOING with connector follow-ups unchanged.<br>2025-10-19 15:25Z: Refreshed `RANGE_PRIMITIVES_COORDINATION.md` matrix + added targeted follow-ups (Cccs, CertBund, ICS-CISA, Kisa, Vndr.Cisco) with delivery dates 2025-10-21 → 2025-10-25; monitoring merge counters for regression.|
|
||||||
|Merge pipeline parity for new advisory fields|BE-Merge|Models, Core|DONE (2025-10-15) – merge service now surfaces description/CWE/canonical metric decisions with updated metrics/tests.|
|
|Merge pipeline parity for new advisory fields|BE-Merge|Models, Core|DONE (2025-10-15) – merge service now surfaces description/CWE/canonical metric decisions with updated metrics/tests.|
|
||||||
|Connector coordination for new advisory fields|Connector Leads, BE-Merge|Models, Core|**DONE (2025-10-15)** – GHSA, NVD, and OSV connectors now emit advisory descriptions, CWE weaknesses, and canonical metric ids. Fixtures refreshed (GHSA connector regression suite, `conflict-nvd.canonical.json`, OSV parity snapshots) and completion recorded in coordination log.|
|
|Connector coordination for new advisory fields|Connector Leads, BE-Merge|Models, Core|**DONE (2025-10-15)** – GHSA, NVD, and OSV connectors now emit advisory descriptions, CWE weaknesses, and canonical metric ids. Fixtures refreshed (GHSA connector regression suite, `conflict-nvd.canonical.json`, OSV parity snapshots) and completion recorded in coordination log.|
|
||||||
|FEEDMERGE-ENGINE-07-001 Conflict sets & explainers|BE-Merge|FEEDSTORAGE-DATA-07-001|**DOING (2025-10-19)** – Merge now captures canonical advisory statements + prepares conflict payload scaffolding (statement hashes, deterministic JSON, tests). Next: surface conflict explainers and replay APIs for Core/WebService before marking DONE.|
|
|FEEDMERGE-ENGINE-07-001 Conflict sets & explainers|BE-Merge|FEEDSTORAGE-DATA-07-001|**DONE (2025-10-20)** – Merge surfaces conflict explainers with replay hashes via `MergeConflictSummary`; API exposes structured payloads and integration tests cover deterministic `asOf` hashes.|
|
||||||
|
> Remark (2025-10-20): `AdvisoryMergeService` now returns conflict summaries with deterministic hashes; WebService replay endpoint emits typed explainers verified by new tests.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
@@ -17,6 +19,7 @@ using Mongo2Go;
|
|||||||
using StellaOps.Concelier.Core.Events;
|
using StellaOps.Concelier.Core.Events;
|
||||||
using StellaOps.Concelier.Core.Jobs;
|
using StellaOps.Concelier.Core.Jobs;
|
||||||
using StellaOps.Concelier.Models;
|
using StellaOps.Concelier.Models;
|
||||||
|
using StellaOps.Concelier.Merge.Services;
|
||||||
using StellaOps.Concelier.WebService.Jobs;
|
using StellaOps.Concelier.WebService.Jobs;
|
||||||
using StellaOps.Concelier.WebService.Options;
|
using StellaOps.Concelier.WebService.Options;
|
||||||
using Xunit.Sdk;
|
using Xunit.Sdk;
|
||||||
@@ -271,6 +274,77 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
|||||||
Assert.True(payload.Conflicts is null || payload.Conflicts!.Count == 0);
|
Assert.True(payload.Conflicts is null || payload.Conflicts!.Count == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdvisoryReplayEndpointReturnsConflictExplainer()
|
||||||
|
{
|
||||||
|
var vulnerabilityKey = "CVE-2025-9100";
|
||||||
|
var statementId = Guid.NewGuid();
|
||||||
|
var conflictId = Guid.NewGuid();
|
||||||
|
var recordedAt = DateTimeOffset.Parse("2025-02-01T00:00:00Z", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
using (var scope = _factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var eventLog = scope.ServiceProvider.GetRequiredService<IAdvisoryEventLog>();
|
||||||
|
var advisory = new Advisory(
|
||||||
|
advisoryKey: vulnerabilityKey,
|
||||||
|
title: "Base advisory",
|
||||||
|
summary: "Baseline summary",
|
||||||
|
language: "en",
|
||||||
|
published: recordedAt.AddDays(-1),
|
||||||
|
modified: recordedAt,
|
||||||
|
severity: "critical",
|
||||||
|
exploitKnown: false,
|
||||||
|
aliases: new[] { vulnerabilityKey },
|
||||||
|
references: Array.Empty<AdvisoryReference>(),
|
||||||
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
||||||
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
||||||
|
provenance: Array.Empty<AdvisoryProvenance>());
|
||||||
|
|
||||||
|
var statementInput = new AdvisoryStatementInput(
|
||||||
|
vulnerabilityKey,
|
||||||
|
advisory,
|
||||||
|
recordedAt,
|
||||||
|
Array.Empty<Guid>(),
|
||||||
|
StatementId: statementId,
|
||||||
|
AdvisoryKey: advisory.AdvisoryKey);
|
||||||
|
|
||||||
|
await eventLog.AppendAsync(new AdvisoryEventAppendRequest(new[] { statementInput }), CancellationToken.None);
|
||||||
|
|
||||||
|
var explainer = new MergeConflictExplainerPayload(
|
||||||
|
Type: "severity",
|
||||||
|
Reason: "mismatch",
|
||||||
|
PrimarySources: new[] { "vendor" },
|
||||||
|
PrimaryRank: 1,
|
||||||
|
SuppressedSources: new[] { "nvd" },
|
||||||
|
SuppressedRank: 5,
|
||||||
|
PrimaryValue: "CRITICAL",
|
||||||
|
SuppressedValue: "MEDIUM");
|
||||||
|
|
||||||
|
using var conflictDoc = JsonDocument.Parse(explainer.ToCanonicalJson());
|
||||||
|
var conflictInput = new AdvisoryConflictInput(
|
||||||
|
vulnerabilityKey,
|
||||||
|
conflictDoc,
|
||||||
|
recordedAt,
|
||||||
|
new[] { statementId },
|
||||||
|
ConflictId: conflictId);
|
||||||
|
|
||||||
|
await eventLog.AppendAsync(new AdvisoryEventAppendRequest(Array.Empty<AdvisoryStatementInput>(), new[] { conflictInput }), CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = _factory.CreateClient();
|
||||||
|
var response = await client.GetAsync($"/concelier/advisories/{vulnerabilityKey}/replay");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
|
||||||
|
Assert.NotNull(payload);
|
||||||
|
var conflict = Assert.Single(payload!.Conflicts);
|
||||||
|
Assert.Equal(conflictId, conflict.ConflictId);
|
||||||
|
Assert.Equal("severity", conflict.Explainer.Type);
|
||||||
|
Assert.Equal("mismatch", conflict.Explainer.Reason);
|
||||||
|
Assert.Equal("CRITICAL", conflict.Explainer.PrimaryValue);
|
||||||
|
Assert.Equal("MEDIUM", conflict.Explainer.SuppressedValue);
|
||||||
|
Assert.Equal(conflict.Explainer.ComputeHashHex(), conflict.ConflictHash);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MirrorEndpointsServeConfiguredArtifacts()
|
public async Task MirrorEndpointsServeConfiguredArtifacts()
|
||||||
{
|
{
|
||||||
@@ -379,8 +453,49 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
|||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
var response = await client.GetAsync("/concelier/exports/mirror/secure/manifest.json");
|
var response = await client.GetAsync("/concelier/exports/mirror/secure/manifest.json");
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
var authHeader = Assert.Single(response.Headers.WwwAuthenticate);
|
||||||
|
Assert.Equal("Bearer", authHeader.Scheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MirrorEndpointsRespectRateLimits()
|
||||||
|
{
|
||||||
|
using var temp = new TempDirectory();
|
||||||
|
var exportId = "20251019T130000Z";
|
||||||
|
var exportRoot = Path.Combine(temp.Path, exportId);
|
||||||
|
var mirrorRoot = Path.Combine(exportRoot, "mirror");
|
||||||
|
Directory.CreateDirectory(mirrorRoot);
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
Path.Combine(mirrorRoot, "index.json"),
|
||||||
|
"""{\"schemaVersion\":1,\"domains\":[]}"""
|
||||||
|
);
|
||||||
|
|
||||||
|
var environment = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["CONCELIER_MIRROR__ENABLED"] = "true",
|
||||||
|
["CONCELIER_MIRROR__EXPORTROOT"] = temp.Path,
|
||||||
|
["CONCELIER_MIRROR__ACTIVEEXPORTID"] = exportId,
|
||||||
|
["CONCELIER_MIRROR__MAXINDEXREQUESTSPERHOUR"] = "1",
|
||||||
|
["CONCELIER_MIRROR__DOMAINS__0__ID"] = "primary",
|
||||||
|
["CONCELIER_MIRROR__DOMAINS__0__REQUIREAUTHENTICATION"] = "false",
|
||||||
|
["CONCELIER_MIRROR__DOMAINS__0__MAXDOWNLOADREQUESTSPERHOUR"] = "1"
|
||||||
|
};
|
||||||
|
|
||||||
|
using var factory = new ConcelierApplicationFactory(_runner.ConnectionString, environmentOverrides: environment);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var okResponse = await client.GetAsync("/concelier/exports/index.json");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, okResponse.StatusCode);
|
||||||
|
|
||||||
|
var limitedResponse = await client.GetAsync("/concelier/exports/index.json");
|
||||||
|
Assert.Equal((HttpStatusCode)429, limitedResponse.StatusCode);
|
||||||
|
Assert.NotNull(limitedResponse.Headers.RetryAfter);
|
||||||
|
Assert.True(limitedResponse.Headers.RetryAfter!.Delta.HasValue);
|
||||||
|
Assert.True(limitedResponse.Headers.RetryAfter!.Delta!.Value.TotalSeconds > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task JobsEndpointsAllowBypassWhenAuthorityEnabled()
|
public async Task JobsEndpointsAllowBypassWhenAuthorityEnabled()
|
||||||
{
|
{
|
||||||
@@ -553,7 +668,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
|||||||
string ConflictHash,
|
string ConflictHash,
|
||||||
DateTimeOffset AsOf,
|
DateTimeOffset AsOf,
|
||||||
DateTimeOffset RecordedAt,
|
DateTimeOffset RecordedAt,
|
||||||
string Details);
|
string Details,
|
||||||
|
MergeConflictExplainerPayload Explainer);
|
||||||
|
|
||||||
private sealed class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
private sealed class ConcelierApplicationFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using StellaOps.Concelier.WebService.Options;
|
using StellaOps.Concelier.WebService.Options;
|
||||||
@@ -129,6 +130,7 @@ internal static class MirrorEndpointExtensions
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"StellaOps Concelier Mirror\"";
|
||||||
result = Results.StatusCode(StatusCodes.Status401Unauthorized);
|
result = Results.StatusCode(StatusCodes.Status401Unauthorized);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -147,7 +149,7 @@ internal static class MirrorEndpointExtensions
|
|||||||
FileAccess.Read,
|
FileAccess.Read,
|
||||||
FileShare.Read | FileShare.Delete);
|
FileShare.Read | FileShare.Delete);
|
||||||
|
|
||||||
response.Headers.CacheControl = "public, max-age=60";
|
response.Headers.CacheControl = BuildCacheControlHeader(path);
|
||||||
response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
|
response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||||
response.ContentLength = fileInfo.Length;
|
response.ContentLength = fileInfo.Length;
|
||||||
return Task.FromResult(Results.Stream(stream, contentType));
|
return Task.FromResult(Results.Stream(stream, contentType));
|
||||||
@@ -178,4 +180,26 @@ internal static class MirrorEndpointExtensions
|
|||||||
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
|
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
|
||||||
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildCacheControlHeader(string path)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(path);
|
||||||
|
if (fileName is null)
|
||||||
|
{
|
||||||
|
return "public, max-age=60";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(fileName, "index.json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "public, max-age=60";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
fileName.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "public, max-age=300, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "public, max-age=300";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,8 @@ app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
|
|||||||
ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()),
|
ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()),
|
||||||
conflict.AsOf,
|
conflict.AsOf,
|
||||||
conflict.RecordedAt,
|
conflict.RecordedAt,
|
||||||
Details = conflict.CanonicalJson
|
Details = conflict.CanonicalJson,
|
||||||
|
Explainer = MergeConflictExplainerPayload.FromCanonicalJson(conflict.CanonicalJson)
|
||||||
}).ToArray()
|
}).ToArray()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,6 @@
|
|||||||
|Update Concelier operator guide for enforcement cutoff|Docs/Concelier|FSR1 rollout|**DONE (2025-10-12)** – Installation guide emphasises disabling `allowAnonymousFallback` before 2025-12-31 UTC and connects audit signals to the rollout checklist.|
|
|Update Concelier operator guide for enforcement cutoff|Docs/Concelier|FSR1 rollout|**DONE (2025-10-12)** – Installation guide emphasises disabling `allowAnonymousFallback` before 2025-12-31 UTC and connects audit signals to the rollout checklist.|
|
||||||
|Rename plugin drop directory to namespaced path|BE-Base|Plugins|**DONE (2025-10-19)** – Build outputs now target `StellaOps.Concelier.PluginBinaries`/`StellaOps.Authority.PluginBinaries`, plugin host defaults updated, config/docs refreshed, and `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj --no-restore` covers the change.|
|
|Rename plugin drop directory to namespaced path|BE-Base|Plugins|**DONE (2025-10-19)** – Build outputs now target `StellaOps.Concelier.PluginBinaries`/`StellaOps.Authority.PluginBinaries`, plugin host defaults updated, config/docs refreshed, and `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj --no-restore` covers the change.|
|
||||||
|Authority resilience adoption|Concelier WebService, Docs|Plumb Authority client resilience options|**BLOCKED (2025-10-10)** – Roll out retry/offline knobs to deployment docs and confirm CLI parity once LIB5 lands; unblock after resilience options wired and tested.|
|
|Authority resilience adoption|Concelier WebService, Docs|Plumb Authority client resilience options|**BLOCKED (2025-10-10)** – Roll out retry/offline knobs to deployment docs and confirm CLI parity once LIB5 lands; unblock after resilience options wired and tested.|
|
||||||
|CONCELIER-WEB-08-201 – Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|DOING (2025-10-19) – HTTP endpoints wired (`/concelier/exports/index.json`, `/concelier/exports/mirror/*`), mirror options bound/validated, and integration tests added; pending auth docs + smoke in ops handbook.|
|
|CONCELIER-WEB-08-201 – Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|**DONE (2025-10-20)** – Mirror endpoints now enforce per-domain rate limits, emit cache headers, honour Authority/WWW-Authenticate, and docs cover auth + smoke workflows.|
|
||||||
|
> Remark (2025-10-20): Updated ops runbook with token/rate-limit checks and added API tests for Retry-After + unauthorized flows.|
|
||||||
|Wave 0B readiness checkpoint|Team WebService & Authority|Wave 0A completion|BLOCKED (2025-10-19) – FEEDSTORAGE-MONGO-08-001 closed, but remaining Wave 0A items (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open; maintain current DOING workstreams only.|
|
|Wave 0B readiness checkpoint|Team WebService & Authority|Wave 0A completion|BLOCKED (2025-10-19) – FEEDSTORAGE-MONGO-08-001 closed, but remaining Wave 0A items (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open; maintain current DOING workstreams only.|
|
||||||
|
|||||||
Reference in New Issue
Block a user