FUll implementation plan (first draft)
This commit is contained in:
188
SPRINTS.md
188
SPRINTS.md
@@ -1,3 +1,5 @@
|
|||||||
|
This file describe implementation of Stella Ops (docs/README.md). Implementation must respect rules from AGENTS.md (read if you have not).
|
||||||
|
|
||||||
| Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description |
|
| Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| Sprint 1 | Stabilize In-Progress Foundations | src/StellaOps.Concelier.Models/TASKS.md | DONE (2025-10-12) | Team Models & Merge Leads | FEEDMODELS-SCHEMA-01-001 | SemVer primitive range-style metadata<br>Instructions to work:<br>DONE Read ./AGENTS.md and src/StellaOps.Concelier.Models/AGENTS.md. This task lays the groundwork—complete the SemVer helper updates before teammates pick up FEEDMODELS-SCHEMA-01-002/003 and FEEDMODELS-SCHEMA-02-900. Use ./src/FASTER_MODELING_AND_NORMALIZATION.md for the target rule structure. |
|
| Sprint 1 | Stabilize In-Progress Foundations | src/StellaOps.Concelier.Models/TASKS.md | DONE (2025-10-12) | Team Models & Merge Leads | FEEDMODELS-SCHEMA-01-001 | SemVer primitive range-style metadata<br>Instructions to work:<br>DONE Read ./AGENTS.md and src/StellaOps.Concelier.Models/AGENTS.md. This task lays the groundwork—complete the SemVer helper updates before teammates pick up FEEDMODELS-SCHEMA-01-002/003 and FEEDMODELS-SCHEMA-02-900. Use ./src/FASTER_MODELING_AND_NORMALIZATION.md for the target rule structure. |
|
||||||
@@ -138,19 +140,201 @@
|
|||||||
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.MSRC.CSAF/TASKS.md | DONE (2025-10-17) | Team Excititor Connectors – MSRC | EXCITITOR-CONN-MS-01-001 | Deliver AAD onboarding/token cache for MSRC CSAF ingestion. |
|
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.MSRC.CSAF/TASKS.md | DONE (2025-10-17) | Team Excititor Connectors – MSRC | EXCITITOR-CONN-MS-01-001 | Deliver AAD onboarding/token cache for MSRC CSAF ingestion. |
|
||||||
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.Oracle.CSAF/TASKS.md | DONE (2025-10-17) | Team Excititor Connectors – Oracle | EXCITITOR-CONN-ORACLE-01-001 | Implement Oracle CSAF catalogue discovery with CPU calendar awareness. |
|
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.Oracle.CSAF/TASKS.md | DONE (2025-10-17) | Team Excititor Connectors – Oracle | EXCITITOR-CONN-ORACLE-01-001 | Implement Oracle CSAF catalogue discovery with CPU calendar awareness. |
|
||||||
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md | DONE (2025-10-17) | Team Excititor Connectors – Ubuntu | EXCITITOR-CONN-UBUNTU-01-001 | Implement Ubuntu CSAF discovery and channel selection for USN ingestion. |
|
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md | DONE (2025-10-17) | Team Excititor Connectors – Ubuntu | EXCITITOR-CONN-UBUNTU-01-001 | Implement Ubuntu CSAF discovery and channel selection for USN ingestion. |
|
||||||
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | TODO | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-001 | Wire OCI discovery/auth to fetch OpenVEX attestations for configured images. |
|
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | DONE (2025-10-18) | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-001 | Wire OCI discovery/auth to fetch OpenVEX attestations for configured images. |
|
||||||
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | EXCITITOR-CLI-01-001 | Add `excititor` CLI verbs bridging to WebService with consistent auth and offline UX. |
|
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | DONE (2025-10-18) | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-002 | Attestation fetch & verify loop – download DSSE attestations, trigger verification, handle retries/backoff, persist raw statements. |
|
||||||
|
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/TASKS.md | DONE (2025-10-18) | Team Excititor Connectors – OCI | EXCITITOR-CONN-OCI-01-003 | Provenance metadata & policy hooks – emit image, subject digest, issuer, and trust metadata for policy weighting/logging. |
|
||||||
|
| Sprint 6 | Excititor Ingest & Formats | src/StellaOps.Cli/TASKS.md | DONE (2025-10-18) | DevEx/CLI | EXCITITOR-CLI-01-001 | Add `excititor` CLI verbs bridging to WebService with consistent auth and offline UX. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Core/TASKS.md | TODO | Team Excititor Core & Policy | EXCITITOR-CORE-02-001 | Context signal schema prep – extend consensus models with severity/KEV/EPSS fields and update canonical serializers. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Core/TASKS.md | TODO | Team Excititor Core & Policy | EXCITITOR-CORE-02-001 | Context signal schema prep – extend consensus models with severity/KEV/EPSS fields and update canonical serializers. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Policy/TASKS.md | TODO | Team Excititor Policy | EXCITITOR-POLICY-02-001 | Scoring coefficients & weight ceilings – add α/β options, weight boosts, and validation guidance. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Policy/TASKS.md | TODO | Team Excititor Policy | EXCITITOR-POLICY-02-001 | Scoring coefficients & weight ceilings – add α/β options, weight boosts, and validation guidance. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Team Excititor Storage | EXCITITOR-STORAGE-02-001 | Statement events & scoring signals – create immutable VEX statement store plus consensus extensions with indexes/migrations. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Team Excititor Storage | EXCITITOR-STORAGE-02-001 | Statement events & scoring signals – create immutable VEX statement store plus consensus extensions with indexes/migrations. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-004 | Resolve API & signed responses – expose `/excititor/resolve`, return signed consensus/score envelopes, document auth. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-004 | Resolve API & signed responses – expose `/excititor/resolve`, return signed consensus/score envelopes, document auth. |
|
||||||
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.WebService/TASKS.md | TODO | Team Excititor WebService | EXCITITOR-WEB-01-005 | Mirror distribution endpoints – expose download APIs for downstream Excititor instances. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Attestation/TASKS.md | DONE (2025-10-16) | Team Excititor Attestation | EXCITITOR-ATTEST-01-002 | Rekor v2 client integration – ship transparency log client with retries and offline queue. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Attestation/TASKS.md | DONE (2025-10-16) | Team Excititor Attestation | EXCITITOR-ATTEST-01-002 | Rekor v2 client integration – ship transparency log client with retries and offline queue. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Worker/TASKS.md | TODO | Team Excititor Worker | EXCITITOR-WORKER-01-004 | TTL refresh & stability damper – schedule re-resolve loops and guard against status flapping. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Worker/TASKS.md | TODO | Team Excititor Worker | EXCITITOR-WORKER-01-004 | TTL refresh & stability damper – schedule re-resolve loops and guard against status flapping. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | TODO | Team Excititor Export | EXCITITOR-EXPORT-01-005 | Score & resolve envelope surfaces – include signed consensus/score artifacts in exports. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | TODO | Team Excititor Export | EXCITITOR-EXPORT-01-005 | Score & resolve envelope surfaces – include signed consensus/score artifacts in exports. |
|
||||||
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | TODO | Team Excititor Export | EXCITITOR-EXPORT-01-006 | Quiet provenance packaging – attach quieted-by statement IDs, signers, justification codes to exports and attestations. |
|
||||||
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Export/TASKS.md | TODO | Team Excititor Export | EXCITITOR-EXPORT-01-007 | Mirror bundle + domain manifest – publish signed consensus bundles for mirrors. |
|
||||||
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-001 | Excititor mirror connector – ingest signed mirror bundles and map to VexClaims with resume handling. |
|
||||||
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | TODO | Team Core Engine & Storage Analytics | FEEDCORE-ENGINE-07-001 | Advisory event log & asOf queries – surface immutable statements and replay capability. |
|
| Sprint 7 | Contextual Truth Foundations | src/StellaOps.Concelier.Core/TASKS.md | TODO | Team Core Engine & Storage Analytics | FEEDCORE-ENGINE-07-001 | Advisory event log & asOf queries – surface immutable statements and replay capability. |
|
||||||
| 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.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 | TODO | 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 | TODO | 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 | TODO | Team Normalization & Storage Backbone | FEEDSTORAGE-MONGO-08-001 | Causal-consistent Concelier storage sessions<br>Ensure `AddMongoStorage` registers a scoped session facilitator (causal consistency + majority concerns), update repositories to accept optional session handles, and add integration coverage proving read-your-write and monotonic reads across a replica set/election scenario. |
|
| Sprint 8 | Mongo strengthening | src/StellaOps.Concelier.Storage.Mongo/TASKS.md | TODO | Team Normalization & Storage Backbone | FEEDSTORAGE-MONGO-08-001 | Causal-consistent Concelier storage sessions<br>Ensure `AddMongoStorage` registers a scoped session facilitator (causal consistency + majority concerns), update repositories to accept optional session handles, and add integration coverage proving read-your-write and monotonic reads across a replica set/election scenario. |
|
||||||
| Sprint 8 | Mongo strengthening | src/StellaOps.Authority/TASKS.md | TODO | Authority Core & Storage Guild | AUTHSTORAGE-MONGO-08-001 | Harden Authority Mongo usage<br>Introduce scoped MongoDB sessions with `writeConcern`/`readConcern` majority defaults, flow the session through stores used in mutations + follow-up reads, and document middleware pattern for web/API & GraphQL layers. |
|
| Sprint 8 | Mongo strengthening | src/StellaOps.Authority/TASKS.md | TODO | Authority Core & Storage Guild | AUTHSTORAGE-MONGO-08-001 | Harden Authority Mongo usage<br>Introduce scoped MongoDB sessions with `writeConcern`/`readConcern` majority defaults, flow the session through stores used in mutations + follow-up reads, and document middleware pattern for web/API & GraphQL layers. |
|
||||||
| Sprint 8 | Mongo strengthening | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Team Excititor Storage | EXCITITOR-STORAGE-MONGO-08-001 | Causal consistency for Excititor repositories<br>Register Mongo options with majority defaults, push session-aware overloads through raw/export/consensus/cache stores, and extend migration/tests to validate causal reads after writes (including GridFS-backed content) under replica-set failover. |
|
| Sprint 8 | Mongo strengthening | src/StellaOps.Excititor.Storage.Mongo/TASKS.md | TODO | Team Excititor Storage | EXCITITOR-STORAGE-MONGO-08-001 | Causal consistency for Excititor repositories<br>Register Mongo options with majority defaults, push session-aware overloads through raw/export/consensus/cache stores, and extend migration/tests to validate causal reads after writes (including GridFS-backed content) under replica-set failover. |
|
||||||
|
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.Exporter.Json/TASKS.md | TODO | 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 | TODO | Concelier Export Guild | CONCELIER-EXPORT-08-202 | Mirror-ready Trivy DB bundles – ship domain-specific archives + metadata for downstream sync. |
|
||||||
|
| Sprint 8 | Mirror Distribution | src/StellaOps.Concelier.WebService/TASKS.md | TODO | 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 | TODO | 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 | TODO | DevOps Guild | DEVOPS-MIRROR-08-001 | Managed mirror deployments for `*.stella-ops.org` – Helm/Compose overlays, CDN, runbooks. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | TODO | Team Scanner Core | SCANNER-CORE-09-501 | Define shared DTOs (ScanJob, ProgressEvent), error taxonomy, and deterministic ID/timestamp helpers aligning with `ARCHITECTURE_SCANNER.md` §3–§4. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | TODO | Team Scanner Core | SCANNER-CORE-09-502 | Observability helpers (correlation IDs, logging scopes, metric namespacing, deterministic hashes) consumed by WebService/Worker. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Core/TASKS.md | TODO | Team Scanner Core | SCANNER-CORE-09-503 | Security utilities: Authority client factory, OpTok caching, DPoP verifier, restart-time plug-in guardrails for scanner components. |
|
||||||
|
| Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | TODO | BuildX Guild | SP9-BLDX-09-001 | Buildx driver scaffold + handshake with Scanner.Emit (local CAS). |
|
||||||
|
| Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | TODO | BuildX Guild | SP9-BLDX-09-002 | OCI annotations + provenance hand-off to Attestor. |
|
||||||
|
| Sprint 9 | Scanner Build-time | src/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md | TODO | BuildX Guild | SP9-BLDX-09-003 | CI demo: minimal SBOM push & backend report wiring. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-WEB-09-101 | Minimal API host with Authority enforcement, health/ready endpoints, and restart-time plug-in loader per architecture §1, §4. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-WEB-09-102 | `/api/v1/scans` submission/status endpoints with deterministic IDs, validation, and cancellation support. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-WEB-09-103 | Progress streaming (SSE/JSONL) with correlation IDs and ISO-8601 UTC timestamps, documented in API reference. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-WEB-09-104 | Configuration binding for Mongo, MinIO, queue, feature flags; startup diagnostics and fail-fast policy. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-POLICY-09-105 | Policy snapshot loader + schema + OpenAPI (YAML ignore rules, VEX include/exclude, vendor precedence). |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-POLICY-09-106 | `/reports` verdict assembly (Feedser+Vexer+Policy) + signed response envelope. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Team Scanner WebService | SCANNER-POLICY-09-107 | Expose score inputs, config version, and quiet provenance in `/reports` JSON and signed payload. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | TODO | Team Scanner Worker | SCANNER-WORKER-09-201 | Worker host bootstrap with Authority auth, hosted services, and graceful shutdown semantics. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | TODO | Team Scanner Worker | SCANNER-WORKER-09-202 | Lease/heartbeat loop with retry+jitter, poison-job quarantine, structured logging. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | TODO | Team Scanner Worker | SCANNER-WORKER-09-203 | Analyzer dispatch skeleton emitting deterministic stage progress and honoring cancellation tokens. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Worker/TASKS.md | TODO | Team Scanner Worker | SCANNER-WORKER-09-204 | Worker metrics (queue latency, stage duration, failure counts) with OpenTelemetry resource wiring. |
|
||||||
|
| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-CORE-09-001 | Policy schema + binder + diagnostics. |
|
||||||
|
| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-CORE-09-002 | Policy snapshot store + revision digests. |
|
||||||
|
| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-CORE-09-003 | `/policy/preview` API (image digest → projected verdict diff). |
|
||||||
|
| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-CORE-09-004 | Versioned scoring config with schema validation, trust table, and golden fixtures. |
|
||||||
|
| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-CORE-09-005 | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. |
|
||||||
|
| Sprint 9 | Policy Foundations | src/StellaOps.Policy/TASKS.md | TODO | Policy Guild | POLICY-CORE-09-006 | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. |
|
||||||
|
| Sprint 9 | DevOps Foundations | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-HELM-09-001 | Helm/Compose environment profiles (dev/staging/airgap) with deterministic digests. |
|
||||||
|
| Sprint 9 | Docs & Governance | docs/TASKS.md | TODO | Docs Guild, DevEx | DOCS-ADR-09-001 | Establish ADR process and template. |
|
||||||
|
| Sprint 9 | Docs & Governance | docs/TASKS.md | TODO | Docs Guild, Platform Events | DOCS-EVENTS-09-002 | Publish event schema catalog (`docs/events/`) for critical envelopes. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Storage/TASKS.md | TODO | Team Scanner Storage | SCANNER-STORAGE-09-301 | Mongo catalog schemas/indexes for images, layers, artifacts, jobs, lifecycle rules plus migrations. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Storage/TASKS.md | TODO | Team Scanner Storage | SCANNER-STORAGE-09-302 | MinIO layout, immutability policies, client abstraction, and configuration binding. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Storage/TASKS.md | TODO | Team Scanner Storage | SCANNER-STORAGE-09-303 | Repositories/services with dual-write feature flag, deterministic digests, TTL enforcement tests. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Queue/TASKS.md | TODO | Team Scanner Queue | SCANNER-QUEUE-09-401 | Queue abstraction + Redis Streams adapter with ack/claim APIs and idempotency tokens. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Queue/TASKS.md | TODO | Team Scanner Queue | SCANNER-QUEUE-09-402 | Pluggable backend support (Redis, NATS) with configuration binding, health probes, failover docs. |
|
||||||
|
| Sprint 9 | Scanner Core Foundations | src/StellaOps.Scanner.Queue/TASKS.md | TODO | Team Scanner Queue | SCANNER-QUEUE-09-403 | Retry + dead-letter strategy with structured logs/metrics for offline deployments. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-101 | Implement layer cache store keyed by layer digest with metadata retention per architecture §3.3. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-102 | Build file CAS with dedupe, TTL enforcement, and offline import/export hooks. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-103 | Expose cache metrics/logging and configuration toggles for warm/cold thresholds. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Cache/TASKS.md | TODO | Scanner Cache Guild | SCANNER-CACHE-10-104 | Implement cache invalidation workflows (layer delete, TTL expiry, diff invalidation). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-201 | Alpine/apk analyzer emitting deterministic components with provenance. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-202 | Debian/dpkg analyzer mapping packages to purl identity with evidence. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-203 | RPM analyzer capturing EVR, file listings, provenance. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-204 | Shared OS evidence helpers for package identity + provenance. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-205 | Vendor metadata enrichment (source packages, license, CVE hints). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-206 | Determinism harness + fixtures for OS analyzers. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.OS/TASKS.md | TODO | OS Analyzer Guild | SCANNER-ANALYZERS-OS-10-207 | Package OS analyzers as restart-time plug-ins (manifest + host registration). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-301 | Java analyzer emitting `pkg:maven` with provenance. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-302 | Node analyzer handling workspaces/symlinks emitting `pkg:npm`. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-303 | Python analyzer reading `*.dist-info`, RECORD hashes, entry points. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-304 | Go analyzer leveraging buildinfo for `pkg:golang` components. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-305 | .NET analyzer parsing `*.deps.json`, assembly metadata, RID variants. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-306 | Rust analyzer detecting crates or falling back to `bin:{sha256}`. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Shared language evidence helpers + usage flag propagation. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-308 | Determinism + fixture harness for language analyzers. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Analyzers.Lang/TASKS.md | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-309 | Package language analyzers as restart-time plug-ins (manifest + host registration). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-401 | POSIX shell AST parser with deterministic output. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-402 | Command resolution across layered rootfs with evidence attribution. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-403 | Interpreter tracing for shell wrappers to Python/Node/Java launchers. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-404 | Python entry analyzer (venv shebang, module invocation, usage flag). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-405 | Node/Java launcher analyzer capturing script/jar targets. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-406 | Explainability + diagnostics for unresolved constructs with metrics. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.EntryTrace/TASKS.md | TODO | EntryTrace Guild | SCANNER-ENTRYTRACE-10-407 | Package EntryTrace analyzers as restart-time plug-ins (manifest + host registration). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Diff/TASKS.md | TODO | Diff Guild | SCANNER-DIFF-10-501 | Build component differ tracking add/remove/version changes with deterministic ordering. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Diff/TASKS.md | TODO | Diff Guild | SCANNER-DIFF-10-502 | Attribute diffs to introducing/removing layers including provenance evidence. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Diff/TASKS.md | TODO | Diff Guild | SCANNER-DIFF-10-503 | Produce JSON diff output for inventory vs usage views aligned with API contract. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-601 | Compose inventory SBOM (CycloneDX JSON/Protobuf) from layer fragments. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-602 | Compose usage SBOM leveraging EntryTrace to flag actual usage. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-603 | Generate BOM index sidecar (purl table + roaring bitmap + usage flag). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-604 | Package artifacts for export + attestation with deterministic manifests. |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-605 | Emit BOM-Index sidecar schema/fixtures (CRITICAL PATH for SP16). |
|
||||||
|
| Sprint 10 | Scanner Analyzers & SBOM | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-10-606 | Usage view bit flags integrated with EntryTrace. |
|
||||||
|
| 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 | Samples | samples/TASKS.md | TODO | Samples Guild, Scanner Team | SAMPLES-10-001 | Sample images with SBOM/BOM-Index sidecars. |
|
||||||
|
| 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 | TODO | 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 | TODO | 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.Signer/TASKS.md | TODO | Signer Guild | SIGNER-API-11-101 | `/sign/dsse` pipeline with Authority auth, PoE introspection, release verification, DSSE signing. |
|
||||||
|
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | TODO | Signer Guild | SIGNER-REF-11-102 | `/verify/referrers` endpoint with OCI lookup, caching, and policy enforcement. |
|
||||||
|
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Signer/TASKS.md | TODO | Signer Guild | SIGNER-QUOTA-11-103 | Enforce plan quotas, concurrency/QPS limits, artifact size caps with metrics/audit logs. |
|
||||||
|
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-API-11-201 | `/rekor/entries` submission pipeline with dedupe, proof acquisition, and persistence. |
|
||||||
|
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-VERIFY-11-202 | `/rekor/verify` + retrieval endpoints validating signatures and Merkle proofs. |
|
||||||
|
| Sprint 11 | Signing Chain Bring-up | src/StellaOps.Attestor/TASKS.md | TODO | Attestor Guild | ATTESTOR-OBS-11-203 | Telemetry, alerting, mTLS hardening, and archive workflow for Attestor. |
|
||||||
|
| Sprint 11 | UI Integration | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-ATTEST-11-005 | Attestation visibility (Rekor id, status) on Scan Detail. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-CORE-12-201 | Define runtime event/admission DTOs, hashing helpers, and versioning strategy. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-CORE-12-202 | Provide configuration/logging/metrics utilities shared by Observer/Webhook. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-CORE-12-203 | Authority client helpers, OpTok caching, and security guardrails for runtime services. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Core/TASKS.md | TODO | Zastava Core Guild | ZASTAVA-OPS-12-204 | Operational runbooks, alert rules, and dashboard exports for runtime plane. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-001 | Container lifecycle watcher emitting deterministic runtime events with buffering. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Capture entrypoint traces + loaded libraries, hashing binaries and linking to baseline SBOM. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-003 | Posture checks for signatures/SBOM/attestation with offline caching. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-12-004 | Batch `/runtime/events` submissions with disk-backed buffer and rate limits. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-101 | Admission controller host with TLS bootstrap and Authority auth. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-102 | Query Scanner `/policy/runtime`, resolve digests, enforce verdicts. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Zastava.Webhook/TASKS.md | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-103 | Caching, fail-open/closed toggles, metrics/logging for admission decisions. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-301 | `/runtime/events` ingestion endpoint with validation, batching, storage hooks. |
|
||||||
|
| Sprint 12 | Runtime Guardrails | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-12-302 | `/policy/runtime` endpoint joining SBOM baseline + policy verdict with TTL guidance. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-AUTH-13-001 | Integrate Authority OIDC + DPoP flows with session management. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCANS-13-002 | Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-VEX-13-003 | Implement VEX explorer + policy editor with preview integration. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-ADMIN-13-004 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCHED-13-005 | Scheduler panel: schedules CRUD, run history, dry-run preview. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-NOTIFY-13-006 | Notify panel: channels/rules CRUD, deliveries view, test send. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-RUNTIME-13-005 | Add runtime policy test verbs that consume `/policy/runtime` and display verdicts. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-OFFLINE-13-006 | Implement offline kit pull/import/status commands with integrity checks. |
|
||||||
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-PLUGIN-13-007 | Package non-core CLI verbs as restart-time plug-ins (manifest + loader tests). |
|
||||||
|
| Sprint 14 | Release & Offline Ops | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-14-001 | Deterministic build/release pipeline with SBOM/provenance, signing, and manifest generation. |
|
||||||
|
| Sprint 14 | Release & Offline Ops | ops/offline-kit/TASKS.md | TODO | Offline Kit Guild | DEVOPS-OFFLINE-14-002 | Offline kit packaging workflow with integrity verification and documentation. |
|
||||||
|
| Sprint 14 | Release & Offline Ops | ops/deployment/TASKS.md | TODO | Deployment Guild | DEVOPS-OPS-14-003 | Deployment/update/rollback automation and channel management documentation. |
|
||||||
|
| Sprint 14 | Release & Offline Ops | ops/licensing/TASKS.md | TODO | Licensing Guild | DEVOPS-LIC-14-004 | Registry token service tied to Authority, plan gating, revocation handling, monitoring. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Models/TASKS.md | TODO | Notify Models Guild | NOTIFY-MODELS-15-101 | Define core Notify DTOs, validation helpers, canonical serialization. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Models/TASKS.md | TODO | Notify Models Guild | NOTIFY-MODELS-15-102 | Publish schema docs and sample payloads for Notify. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Models/TASKS.md | TODO | Notify Models Guild | NOTIFY-MODELS-15-103 | Versioning/migration helpers for rules/templates/deliveries. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Storage.Mongo/TASKS.md | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-201 | Mongo schemas/indexes for rules, channels, deliveries, digests, locks, audit. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Storage.Mongo/TASKS.md | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-202 | Repositories with tenant scoping, soft delete, TTL, causal consistency options. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Storage.Mongo/TASKS.md | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-203 | Delivery history retention and query APIs. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Bus abstraction + Redis Streams adapter with ordering/idempotency. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-402 | NATS JetStream adapter with health probes and failover. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Queue/TASKS.md | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-403 | Delivery queue with retry/dead-letter + metrics. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-301 | Rules evaluation core (filters, throttles, idempotency). |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-302 | Action planner + digest coalescer. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-303 | Template rendering engine (Slack/Teams/Email/Webhook). |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Engine/TASKS.md | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-304 | Test-send sandbox + preview utilities. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.WebService/TASKS.md | TODO | Notify WebService Guild | NOTIFY-WEB-15-101 | Minimal API host with Authority enforcement and plug-in loading. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.WebService/TASKS.md | TODO | Notify WebService Guild | NOTIFY-WEB-15-102 | Rules/channel/template CRUD with audit logging. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.WebService/TASKS.md | TODO | Notify WebService Guild | NOTIFY-WEB-15-103 | Delivery history & test-send endpoints. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.WebService/TASKS.md | TODO | Notify WebService Guild | NOTIFY-WEB-15-104 | Configuration binding + startup diagnostics. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-201 | Bus subscription + leasing loop with backoff. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-202 | Rules evaluation pipeline integration. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-203 | Channel dispatch orchestration with retries. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Worker/TASKS.md | TODO | Notify Worker Guild | NOTIFY-WORKER-15-204 | Metrics/telemetry for Notify workers. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Slack/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-SLACK-15-501 | Slack connector with rate-limit aware delivery. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Slack/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-SLACK-15-502 | Slack health/test-send support. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Teams/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-TEAMS-15-601 | Teams connector with Adaptive Cards. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Teams/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-TEAMS-15-602 | Teams health/test-send support. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Email/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-EMAIL-15-701 | SMTP connector with TLS + rendering. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Email/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-EMAIL-15-702 | DKIM + health/test-send flows. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Webhook/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-WEBHOOK-15-801 | Webhook connector with signing/retries. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Webhook/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-WEBHOOK-15-802 | Webhook health/test-send support. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Slack/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-SLACK-15-503 | Package Slack connector as restart-time plug-in (manifest + host registration). |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Teams/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-TEAMS-15-603 | Package Teams connector as restart-time plug-in (manifest + host registration). |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Email/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-EMAIL-15-703 | Package Email connector as restart-time plug-in (manifest + host registration). |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-EVENTS-15-201 | Emit `scanner.report.ready` + `scanner.scan.completed` events. |
|
||||||
|
| Sprint 15 | Benchmarks | bench/TASKS.md | TODO | Bench Guild, Notify Team | BENCH-NOTIFY-15-001 | Notify dispatch throughput bench with results CSV. |
|
||||||
|
| Sprint 15 | Notify Foundations | src/StellaOps.Notify.Connectors.Webhook/TASKS.md | TODO | Notify Connectors Guild | NOTIFY-CONN-WEBHOOK-15-803 | Package Webhook connector as restart-time plug-in (manifest + host registration). |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Models/TASKS.md | TODO | Scheduler Models Guild | SCHED-MODELS-16-101 | Define Scheduler DTOs & validation. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Models/TASKS.md | TODO | Scheduler Models Guild | SCHED-MODELS-16-102 | Publish schema docs/sample payloads. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Models/TASKS.md | TODO | Scheduler Models Guild | SCHED-MODELS-16-103 | Versioning/migration helpers for schedules/runs. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-201 | Mongo schemas/indexes for Scheduler state. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-202 | Repositories with tenant scoping, TTL, causal consistency. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Storage.Mongo/TASKS.md | TODO | Scheduler Storage Guild | SCHED-STORAGE-16-203 | Audit + stats materialization for UI. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Queue/TASKS.md | TODO | Scheduler Queue Guild | SCHED-QUEUE-16-401 | Queue abstraction + Redis Streams adapter. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Queue/TASKS.md | TODO | Scheduler Queue Guild | SCHED-QUEUE-16-402 | NATS JetStream adapter with health probes. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Queue/TASKS.md | TODO | Scheduler Queue Guild | SCHED-QUEUE-16-403 | Dead-letter handling + metrics. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-301 | Ingest BOM-Index into roaring bitmap store. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-302 | Query APIs for ResolveByPurls/ResolveByVulns/ResolveAll. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | TODO | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-303 | Snapshot/compaction/invalidation workflow. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.ImpactIndex/TASKS.md | DOING | Scheduler ImpactIndex Guild | SCHED-IMPACT-16-300 | **STUB** ImpactIndex ingest/query using fixtures (to be removed by SP16 completion). |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | TODO | Scheduler WebService Guild | SCHED-WEB-16-101 | Minimal API host with Authority enforcement. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | TODO | Scheduler WebService Guild | SCHED-WEB-16-102 | Schedules CRUD (cron validation, pause/resume, audit). |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | TODO | Scheduler WebService Guild | SCHED-WEB-16-103 | Runs API (list/detail/cancel) + impact previews. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.WebService/TASKS.md | TODO | Scheduler WebService Guild | SCHED-WEB-16-104 | Feedser/Vexer webhook handlers with security enforcement. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-201 | Planner loop (cron/event triggers, leases, fairness). |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-202 | ImpactIndex targeting and shard planning. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-203 | Runner execution invoking Scanner analysis/content refresh. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-204 | Emit rescan/report events for Notify/UI. |
|
||||||
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-205 | Metrics/telemetry for Scheduler planners/runners. |
|
||||||
|
| Sprint 16 | Benchmarks | bench/TASKS.md | TODO | Bench Guild, Scheduler Team | BENCH-IMPACT-16-001 | ImpactIndex throughput bench + RAM profile. |
|
||||||
|
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-17-701 | Record GNU build-id for ELF components and surface it in SBOM/diff outputs. |
|
||||||
|
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Zastava.Observer/TASKS.md | TODO | Zastava Observer Guild | ZASTAVA-OBS-17-005 | Collect GNU build-id during runtime observation and attach it to emitted events. |
|
||||||
|
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.WebService/TASKS.md | TODO | Scanner WebService Guild | SCANNER-RUNTIME-17-401 | Persist runtime build-id observations and expose them for debug-symbol correlation. |
|
||||||
|
| Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-17-002 | Ship stripped debug artifacts organised by build-id within release/offline kits. |
|
||||||
|
| Sprint 17 | Symbol Intelligence & Forensics | docs/TASKS.md | TODO | Docs Guild | DOCS-RUNTIME-17-004 | Document build-id workflows for SBOMs, runtime events, and debug-store usage. |
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
| Sprint | Theme | Tasks File Path | Status | Type of Specialist | Task ID | Task Description |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
289
SPRINTS_IMPLEMENTION_PLAN.md
Normal file
289
SPRINTS_IMPLEMENTION_PLAN.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# StellaOps Multi-Sprint Implementation Plan (Agile Track)
|
||||||
|
|
||||||
|
This plan translates the current `SPRINTS.md` (read the file if you have not) backlog into parallel-friendly execution clusters. Each sprint is decomposed into **groups** that can run concurrently without stepping on the same directories. For every group we capture:
|
||||||
|
|
||||||
|
- **Tasks** (ID · est. effort · path)
|
||||||
|
- **Acceptance metrics** (quantitative targets to reduce rework)
|
||||||
|
- **Gate** artifacts required before dependent groups can start
|
||||||
|
|
||||||
|
Durations are estimated work sizes (1 d ≈ one focused engineer day). Milestones are gated by artifacts—not calendar dates—to keep us agile and adaptable to competitor pressure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 9 – Scanner Core Foundations (ID: SP9, ~3 w)
|
||||||
|
|
||||||
|
### Group SP9-G1 — Core Contracts & Observability (src/StellaOps.Scanner.Core) ~1 w
|
||||||
|
- Tasks:
|
||||||
|
- SCANNER-CORE-09-501 · 3 d · `/src/StellaOps.Scanner.Core/TASKS.md`
|
||||||
|
- SCANNER-CORE-09-502 · 2 d · same path
|
||||||
|
- SCANNER-CORE-09-503 · 2 d · same path
|
||||||
|
- Acceptance metrics: DTO round-trip tests stable; middleware adds ≤5 µs per call.
|
||||||
|
- Gate SP9-G1 → WebService: `scanner-core-contracts.md` snippet plus `ScannerCoreContractsTests` green.
|
||||||
|
|
||||||
|
### Group SP9-G2 — Queue Backbone (src/StellaOps.Scanner.Queue) ~1 w
|
||||||
|
- Tasks: SCANNER-QUEUE-09-401 (3 d), -402 (2 d), -403 (2 d) · `/src/StellaOps.Scanner.Queue/TASKS.md`
|
||||||
|
- Acceptance: dequeue latency p95 ≤20 ms at 40 rps; chaos test retains leases.
|
||||||
|
- Gate: Redis/NATS adapters docs + `QueueLeaseIntegrationTests` passing.
|
||||||
|
|
||||||
|
### Group SP9-G3 — Storage Backbone (src/StellaOps.Scanner.Storage) ~1 w
|
||||||
|
- Tasks: SCANNER-STORAGE-09-301 (3 d), -302 (2 d), -303 (2 d)
|
||||||
|
- Acceptance: majority write/read ≤50 ms; TTL verified.
|
||||||
|
- Gate: migrations checked in; `StorageDualWriteFixture` passes.
|
||||||
|
|
||||||
|
### Group SP9-G4 — WebService Host & Policy Surfacing (src/StellaOps.Scanner.WebService) ~1.2 w
|
||||||
|
- Tasks: SCANNER-WEB-09-101 (2 d), -102 (3 d), -103 (2 d), -104 (2 d), SCANNER-POLICY-09-105 (3 d), SCANNER-POLICY-09-106 (4 d)
|
||||||
|
- Acceptance: `/api/v1/scans` enqueue p95 ≤50 ms under synthetic load; policy validation errors actionable; `/reports` response signed.
|
||||||
|
- Gate SP9-G4 → SP10/SP11: `/reports` OpenAPI frozen; sample signed envelope committed in `samples/api/reports/`.
|
||||||
|
|
||||||
|
### Group SP9-G5 — Worker Host (src/StellaOps.Scanner.Worker) ~1 w
|
||||||
|
- Tasks: SCANNER-WORKER-09-201 (3 d), -202 (3 d), -203 (2 d), -204 (2 d)
|
||||||
|
- Acceptance: job lease never drops <3× heartbeat; progress events deterministic.
|
||||||
|
- Gate: `WorkerBasicScanScenario` integration recorded.
|
||||||
|
|
||||||
|
### Group SP9-G6 — Buildx Plug-in (src/StellaOps.Scanner.Sbomer.BuildXPlugin) ~0.8 w
|
||||||
|
- Tasks: SP9-BLDX-09-001 (3 d), SP9-BLDX-09-002 (2 d), SP9-BLDX-09-003 (2 d)
|
||||||
|
- Acceptance: build-time overhead ≤300 ms/layer on 4 vCPU; CAS handshake reliable in CI sample.
|
||||||
|
- Gate: buildx demo workflow artifact + quickstart doc.
|
||||||
|
|
||||||
|
### Group SP9-G7 — Policy Engine Core (src/StellaOps.Policy) ~1 w
|
||||||
|
- Tasks: POLICY-CORE-09-001 (2 d), -002 (3 d), -003 (3 d), -004 (3 d), -005 (4 d), -006 (2 d)
|
||||||
|
- Acceptance: policy parsing ≥200 files/s; preview diff response <200 ms for 500-component SBOM; quieting logic audited.
|
||||||
|
- Gate: `policy-schema@1` published; revision digests stored; preview API doc updated.
|
||||||
|
|
||||||
|
### Group SP9-G8 — DevOps Early Guardrails (ops/devops) ~0.4 w
|
||||||
|
- Tasks: DEVOPS-HELM-09-001 (3 d)
|
||||||
|
- Acceptance: helm/compose profiles for dev/stage/airgap lint + dry-run clean; manifests pinned to digest.
|
||||||
|
- Gate: profiles merged under `deploy/`; install guide cross-link.
|
||||||
|
|
||||||
|
### Group SP9-G9 — Documentation & Events (docs/) ~0.4 w
|
||||||
|
- Tasks: DOCS-ADR-09-001 (2 d), DOCS-EVENTS-09-002 (2 d)
|
||||||
|
- Acceptance: ADR process broadcast; event schemas validated via CI.
|
||||||
|
- Gate: `docs/adr/index.md` linking template; `docs/events/README.md` referencing schemas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 10 – Scanner Analyzers & SBOM (ID: SP10, ~4 w)
|
||||||
|
|
||||||
|
### Group SP10-G1 — OS Analyzer Plug-ins (src/StellaOps.Scanner.Analyzers.OS) ~1 w
|
||||||
|
- Tasks: SCANNER-ANALYZERS-OS-10-201..207 (durations 2–3 d each)
|
||||||
|
- Acceptance: analyzer runtime <1.5 s/image; memory <250 MB.
|
||||||
|
- Gate: plug-ins packaged under `plugins/scanner/analyzers/os/`; determinism CI job green.
|
||||||
|
|
||||||
|
### Group SP10-G2 — Language Analyzer Plug-ins (src/StellaOps.Scanner.Analyzers.Lang) ~1.5 w
|
||||||
|
- Tasks: SCANNER-ANALYZERS-LANG-10-301..309
|
||||||
|
- Acceptance: Node analyzer handles 10 k modules <2 s; Python memory <200 MB.
|
||||||
|
- Gate: golden outputs stored; plugin manifests present.
|
||||||
|
|
||||||
|
### Group SP10-G3 — EntryTrace Plug-ins (src/StellaOps.Scanner.EntryTrace) ~0.8 w
|
||||||
|
- Tasks: SCANNER-ENTRYTRACE-10-401..407
|
||||||
|
- Acceptance: ≥95 % launcher resolution success on samples; unknown reasons enumerated.
|
||||||
|
- Gate: entrytrace plug-ins packaged; explainability doc updated.
|
||||||
|
|
||||||
|
### Group SP10-G4 — SBOM Composition & BOM Index (src/StellaOps.Scanner.Diff + Emit) ~1 w
|
||||||
|
- Tasks: SCANNER-DIFF-10-501..503, SCANNER-EMIT-10-601..606
|
||||||
|
- Acceptance: BOM-Index emission <500 ms/image; diff output deterministic across runs.
|
||||||
|
- Gate SP10-G4 → SP16: `docs/artifacts/bom-index/` schema + fixtures; tests `BOMIndexGoldenIsStable` & `UsageFlagsAreAccurate` green.
|
||||||
|
|
||||||
|
### Group SP10-G5 — Cache Subsystem (src/StellaOps.Scanner.Cache) ~0.6 w
|
||||||
|
- Tasks: SCANNER-CACHE-10-101..104
|
||||||
|
- Acceptance: cache hit instrumentation validated; eviction keeps footprint <5 GB.
|
||||||
|
- Gate: cache configuration doc; integration test `LayerCacheRoundTrip` green.
|
||||||
|
|
||||||
|
### Group SP10-G6 — Benchmarks & Samples (bench/, samples/, ops/devops) ~0.6 w
|
||||||
|
- Tasks: BENCH-SCANNER-10-001 (2 d), SAMPLES-10-001 (finish – 3 d), DEVOPS-PERF-10-001 (2 d)
|
||||||
|
- Acceptance: analyzer benchmark CSV published; perf CI guard ensures SBOM compose <5 s; sample SBOM/BOM-Index committed.
|
||||||
|
- Gate: bench results stored under `bench/`; `samples/` populated; CI job added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 11 – Signing Chain Bring-up (ID: SP11, ~3 w)
|
||||||
|
|
||||||
|
### Group SP11-G1 — Authority Sender Constraints (src/StellaOps.Authority) ~0.8 w
|
||||||
|
- Tasks: AUTH-DPOP-11-001 (3 d), AUTH-MTLS-11-002 (2 d)
|
||||||
|
- Acceptance: DPoP nonce dance validated; mTLS tokens issued in ≤40 ms.
|
||||||
|
- Gate: updated Authority OpenAPI; QA scripts verifying DPoP/mTLS.
|
||||||
|
|
||||||
|
### Group SP11-G2 — Signer Service (src/StellaOps.Signer) ~1.2 w
|
||||||
|
- Tasks: SIGNER-API-11-101 (4 d), SIGNER-REF-11-102 (2 d), SIGNER-QUOTA-11-103 (2 d)
|
||||||
|
- Acceptance: signing throughput ≥30 req/min; p95 latency ≤200 ms.
|
||||||
|
- Gate SP11-G2 → Attestor/UI: `/sign/dsse` OpenAPI frozen; signed DSSE bundle in repo; Rekor interop test passing.
|
||||||
|
|
||||||
|
### Group SP11-G3 — Attestor Service (src/StellaOps.Attestor) ~1 w
|
||||||
|
- Tasks: ATTESTOR-API-11-201 (3 d), ATTESTOR-VERIFY-11-202 (2 d), ATTESTOR-OBS-11-203 (2 d)
|
||||||
|
- Acceptance: inclusion proof retrieval <500 ms; audit log coverage 100 %.
|
||||||
|
- Gate: Attestor API doc + verification script.
|
||||||
|
|
||||||
|
### Group SP11-G4 — UI Attestation Hooks (src/StellaOps.UI) ~0.4 w
|
||||||
|
- Tasks: UI-ATTEST-11-005 (3 d)
|
||||||
|
- Acceptance: attestation panel renders within 200 ms; Rekor link verified.
|
||||||
|
- Gate SP11-G4 → SP13-G1: recorded UX walkthrough.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 12 – Runtime Guardrails (ID: SP12, ~3 w)
|
||||||
|
|
||||||
|
### Group SP12-G1 — Zastava Core (src/StellaOps.Zastava.Core) ~0.8 w
|
||||||
|
- Tasks: ZASTAVA-CORE-12-201..204
|
||||||
|
- Acceptance: DTO tests stable; configuration docs produced.
|
||||||
|
- Gate: schema doc + logging helpers integrated.
|
||||||
|
|
||||||
|
### Group SP12-G2 — Zastava Observer (src/StellaOps.Zastava.Observer) ~0.8 w
|
||||||
|
- Tasks: ZASTAVA-OBS-12-001..004
|
||||||
|
- Acceptance: observer memory <200 MB; event flush ≤2 s.
|
||||||
|
- Gate: sample runtime events stored; offline buffer test passes.
|
||||||
|
|
||||||
|
### Group SP12-G3 — Zastava Webhook (src/StellaOps.Zastava.Webhook) ~0.6 w
|
||||||
|
- Tasks: ZASTAVA-WEBHOOK-12-101..103
|
||||||
|
- Acceptance: admission latency p95 ≤45 ms; cache TTL adhered to.
|
||||||
|
- Gate: TLS rotation procedure documented; readiness probe script.
|
||||||
|
|
||||||
|
### Group SP12-G4 — Scanner Runtime APIs (src/StellaOps.Scanner.WebService) ~0.8 w
|
||||||
|
- Tasks: SCANNER-RUNTIME-12-301 (2 d), SCANNER-RUNTIME-12-302 (3 d)
|
||||||
|
- Acceptance: `/runtime/events` handles 500 events/sec; `/policy/runtime` output matches webhook decisions.
|
||||||
|
- Gate SP12-G4 → SP13/SP15: API documented, fixtures updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 13 – UX & CLI Experience (ID: SP13, ~2 w)
|
||||||
|
|
||||||
|
### Group SP13-G1 — UI Shell & Panels (src/StellaOps.UI) ~1.6 w
|
||||||
|
- Tasks: UI-AUTH-13-001 (3 d), UI-SCANS-13-002 (4 d), UI-VEX-13-003 (3 d), UI-ADMIN-13-004 (2 d), UI-SCHED-13-005 (3 d), UI-NOTIFY-13-006 (3 d)
|
||||||
|
- Acceptance: Lighthouse ≥85; Scheduler/Notify panels function against mocked APIs.
|
||||||
|
- Gate: UI dev server fixtures committed; QA sign-off captured.
|
||||||
|
|
||||||
|
### Group SP13-G2 — CLI Enhancements (src/StellaOps.Cli) ~0.8 w
|
||||||
|
- Tasks: CLI-RUNTIME-13-005 (3 d), CLI-OFFLINE-13-006 (3 d), CLI-PLUGIN-13-007 (2 d)
|
||||||
|
- Acceptance: runtime policy CLI completes <1 s for 10 images; offline kit commands resume downloads.
|
||||||
|
- Gate: CLI plugin manifest doc; smoke tests covering new verbs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 14 – Release & Offline Ops (ID: SP14, ~2 w)
|
||||||
|
|
||||||
|
### Group SP14-G1 — Release Automation (ops/devops) ~0.8 w
|
||||||
|
- Tasks: DEVOPS-REL-14-001 (4 d)
|
||||||
|
- Acceptance: reproducible build diff tool shows zero drift across two runs; signing pipeline green.
|
||||||
|
- Gate: signed manifest + provenance published.
|
||||||
|
|
||||||
|
### Group SP14-G2 — Offline Kit Packaging (ops/offline-kit) ~0.6 w
|
||||||
|
- Tasks: DEVOPS-OFFLINE-14-002 (3 d)
|
||||||
|
- Acceptance: kit import <5 min with integrity verification CLI.
|
||||||
|
- Gate: kit doc updated; import script included.
|
||||||
|
|
||||||
|
### Group SP14-G3 — Deployment Playbooks (ops/deployment) ~0.4 w
|
||||||
|
- Tasks: DEVOPS-OPS-14-003 (2 d)
|
||||||
|
- Acceptance: rollback drill recorded; compatibility matrix produced.
|
||||||
|
- Gate: playbook PR merged with Ops sign-off.
|
||||||
|
|
||||||
|
### Group SP14-G4 — Licensing Token Service (ops/licensing) ~0.4 w
|
||||||
|
- Tasks: DEVOPS-LIC-14-004 (2 d)
|
||||||
|
- Acceptance: token service handles 100 req/min; revocation latency <60 s.
|
||||||
|
- Gate: monitoring dashboard links; failover doc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 15 – Notify Foundations (ID: SP15, ~3 w)
|
||||||
|
|
||||||
|
### Group SP15-G1 — Models & Storage (src/StellaOps.Notify.Models + Storage.Mongo) ~0.8 w
|
||||||
|
- Tasks: NOTIFY-MODELS-15-101 (2 d), -102 (2 d), -103 (1 d); NOTIFY-STORAGE-15-201 (3 d), -202 (2 d), -203 (1 d)
|
||||||
|
- Acceptance: rule CRUD latency <120 ms; delivery retention job verified.
|
||||||
|
- Gate: schema docs + fixtures published.
|
||||||
|
|
||||||
|
### Group SP15-G2 — Engine & Queue (src/StellaOps.Notify.Engine + Queue) ~0.8 w
|
||||||
|
- Tasks: NOTIFY-ENGINE-15-301..304, NOTIFY-QUEUE-15-401..403
|
||||||
|
- Acceptance: rules evaluation ≥5k events/min; queue dead-letter <0.5 %.
|
||||||
|
- Gate: digest outputs committed; queue config doc updated.
|
||||||
|
|
||||||
|
### Group SP15-G3 — WebService & Worker (src/StellaOps.Notify.WebService + Worker) ~0.8 w
|
||||||
|
- Tasks: NOTIFY-WEB-15-101..104, NOTIFY-WORKER-15-201..204
|
||||||
|
- Acceptance: API p95 <120 ms; worker delivery success ≥99 %.
|
||||||
|
- Gate: end-to-end fixture run producing delivery record.
|
||||||
|
|
||||||
|
### Group SP15-G4 — Channel Plug-ins (src/StellaOps.Notify.Connectors.*) ~0.6 w
|
||||||
|
- Tasks: NOTIFY-CONN-SLACK-15-501..503, NOTIFY-CONN-TEAMS-15-601..603, NOTIFY-CONN-EMAIL-15-701..703, NOTIFY-CONN-WEBHOOK-15-801..803
|
||||||
|
- Acceptance: channel-specific retry policies verified; rate limits respected.
|
||||||
|
- Gate: plug-in manifests inside `plugins/notify/**`; test-send docs.
|
||||||
|
|
||||||
|
### Group SP15-G5 — Events & Benchmarks (src/StellaOps.Scanner.WebService + bench) ~0.5 w
|
||||||
|
- Tasks: SCANNER-EVENTS-15-201 (2 d), BENCH-NOTIFY-15-001 (2 d)
|
||||||
|
- Acceptance: event emission latency <100 ms; throughput bench results stored.
|
||||||
|
- Gate: `docs/events/samples/` contains sample payloads; bench CSV in repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 16 – Scheduler Intelligence (ID: SP16, ~4 w)
|
||||||
|
|
||||||
|
### Group SP16-G1 — Models & Storage (src/StellaOps.Scheduler.Models + Storage.Mongo) ~1 w
|
||||||
|
- Tasks: SCHED-MODELS-16-101 (3 d), -102 (2 d), -103 (2 d); SCHED-STORAGE-16-201 (3 d), -202 (2 d), -203 (2 d)
|
||||||
|
- Acceptance: schedule CRUD latency <120 ms; run retention TTL enforced.
|
||||||
|
- Gate: schema doc + integration tests passing.
|
||||||
|
|
||||||
|
### Group SP16-G2 — ImpactIndex & Queue (src/StellaOps.Scheduler.ImpactIndex + Queue + Bench) ~1.2 w
|
||||||
|
- Tasks: SCHED-IMPACT-16-300 (2 d, DOING), SCHED-IMPACT-16-301 (3 d), -302 (3 d), -303 (2 d); SCHED-QUEUE-16-401..403 (each 2 d); BENCH-IMPACT-16-001 (2 d)
|
||||||
|
- Acceptance: impact resolve 10k productKeys <300 ms hot; stub removed by sprint end.
|
||||||
|
- Gate: roaring snapshot stored; bench CSV published; removal plan for stub recorded.
|
||||||
|
|
||||||
|
### Group SP16-G3 — Scheduler WebService (src/StellaOps.Scheduler.WebService) ~0.8 w
|
||||||
|
- Tasks: SCHED-WEB-16-101..104 (each 2 d)
|
||||||
|
- Acceptance: preview endpoint <250 ms; webhook security enforced.
|
||||||
|
- Gate: OpenAPI published; dry-run JSON fixtures stored.
|
||||||
|
|
||||||
|
### Group SP16-G4 — Scheduler Worker (src/StellaOps.Scheduler.Worker) ~1 w
|
||||||
|
- Tasks: SCHED-WORKER-16-201 (3 d), -202 (2 d), -203 (3 d), -204 (2 d), -205 (2 d)
|
||||||
|
- Acceptance: planner fairness metrics captured; runner success ≥98 % across 1k sims.
|
||||||
|
- Gate: event emission to Notify verified; metrics dashboards live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 17 – Symbol Intelligence & Forensics (ID: SP17, ~2.5 w)
|
||||||
|
|
||||||
|
### Group SP17-G1 — Scanner Forensics (src/StellaOps.Scanner.Emit + WebService) ~1.2 w
|
||||||
|
- Tasks: SCANNER-EMIT-17-701 (4 d), SCANNER-RUNTIME-17-401 (3 d)
|
||||||
|
- Acceptance: forensic overlays add ≤150 ms per image; runtime API exposes symbol hints with feature flag.
|
||||||
|
- Gate: forensic SBOM samples committed; API doc updated.
|
||||||
|
|
||||||
|
### Group SP17-G2 — Zastava Observability (src/StellaOps.Zastava.Observer) ~0.6 w
|
||||||
|
- Tasks: ZASTAVA-OBS-17-005 (3 d)
|
||||||
|
- Acceptance: new telemetry surfaces symbol diffs; observer CPU <10 % under load.
|
||||||
|
- Gate: Grafana dashboard export, alert thresholds defined.
|
||||||
|
|
||||||
|
### Group SP17-G3 — Release Hardening (ops/devops) ~0.4 w
|
||||||
|
- Tasks: DEVOPS-REL-17-002 (2 d)
|
||||||
|
- Acceptance: deterministic build verifier job updated to include forensics artifacts.
|
||||||
|
- Gate: CI pipeline stage `forensics-verify` green.
|
||||||
|
|
||||||
|
### Group SP17-G4 — Documentation (docs/) ~0.3 w
|
||||||
|
- Tasks: DOCS-RUNTIME-17-004 (2 d)
|
||||||
|
- Acceptance: runtime forensic guide published with troubleshooting.
|
||||||
|
- Gate: docs review sign-off; links added to UI help.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Buffers
|
||||||
|
- **INT-A (0.3 w, after SP10):** Image → SBOM → BOM-Index → Scheduler preview → UI dry-run using fixtures.
|
||||||
|
- **INT-B (0.3 w, after SP11 & SP15):** SBOM → policy verdict → signed DSSE → Rekor entry → Notify delivery end-to-end.
|
||||||
|
|
||||||
|
## Parallelisation Strategy
|
||||||
|
- SP9 core modules and SP11 authority upgrades can progress in parallel; scanner clients rely on feature flags while DPoP/mTLS hardening lands.
|
||||||
|
- SP10 SBOM emission may start alongside Scheduler ImpactIndex using `samples/` fixtures; stub SCHED-IMPACT-16-300 keeps velocity while awaiting roaring index.
|
||||||
|
- Notify foundations (SP15) can begin once event schemas freeze (delivered in SP9-G9/SP12-G4), consuming canned events until Scanner emits live ones.
|
||||||
|
- UI (SP13) uses mocked endpoints early, decoupling front-end delivery from backend readiness.
|
||||||
|
|
||||||
|
## Risk Registry
|
||||||
|
|
||||||
|
| Risk ID | Description | Owner | Mitigation | Trigger |
|
||||||
|
|---------|-------------|-------|-----------|---------|
|
||||||
|
| R1 | BOM-Index memory blow-up on large fleets | Scheduler ImpactIndex Guild | Shard + mmap plan; monitor BENCH-IMPACT-16-001 | RAM > 8 GB in bench |
|
||||||
|
| R2 | Buildx plugin latency regression | BuildX Guild | DEVOPS-PERF-10-001 guard; fallback to post-build scan | Buildx job >300 ms/layer |
|
||||||
|
| R3 | Notify digests flooding Slack | Notify Engine Guild | throttle defaults, BENCH-NOTIFY-15-001 coverage | Dropped messages >1 % |
|
||||||
|
| R4 | Policy precedence confusion | Policy Guild | ADR, preview API, unit tests | Operator escalation about precedence |
|
||||||
|
| R5 | ImpactIndex stub lingers | Scheduler ImpactIndex Guild | Track SCHED-IMPACT-16-300 removal in sprint review | Stub present past SP16 |
|
||||||
|
| R6 | Symbol forensics slows runtime | Scanner Emit Guild | Feature flag; perf tests in SP17-G1 | Forensics adds >150 ms/image |
|
||||||
|
|
||||||
|
## Envelope & ADR Governance
|
||||||
|
- Event schemas (`docs/events/*.json`) versioned; producers must bump suffix on breaking changes.
|
||||||
|
- ADR template (`docs/adr/0000-template.md`) mandatory for BOM-Index format, event envelopes, DPoP nonce policy, Rekor migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Summary:** The plan keeps high-impact artifacts (policy engine, BOM-Index, signing chain) on the critical path while unlocking parallel tracks (Notify, Scheduler, UI) through early schema freezes and fixtures. Integration buffers ensure cross-team touchpoints are validated continuously, supporting rapid iteration against competitive pressure.
|
||||||
7
bench/TASKS.md
Normal file
7
bench/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Benchmarks Task Board
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| BENCH-SCANNER-10-001 | TODO | Bench Guild, Scanner Team | SCANNER-ANALYZERS-LANG-10-303 | Analyzer microbench harness (node_modules, site-packages) + baseline CSV. | Harness committed under `bench/Scanner.Analyzers`; baseline CSV recorded; CI job publishes results. |
|
||||||
|
| BENCH-IMPACT-16-001 | TODO | Bench Guild, Scheduler Team | SCHED-IMPACT-16-301 | ImpactIndex throughput bench (resolve 10k productKeys) + RAM profile. | Benchmark script ready; baseline metrics recorded; alert thresholds defined. |
|
||||||
|
| BENCH-NOTIFY-15-001 | TODO | Bench Guild, Notify Team | NOTIFY-ENGINE-15-301 | Notify dispatch throughput bench (vary rule density) with results CSV. | Bench executed; results stored; regression alert configured. |
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
Below is the **revised, consolidated** `high_level_architecture.md`.
|
|
||||||
It **absorbs** all content from `components.md` so you have a single, authoritative file. No separate components doc is required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# High‑Level Architecture — **Stella Ops** (Consolidated • 2025Q4)
|
# High‑Level Architecture — **Stella Ops** (Consolidated • 2025Q4)
|
||||||
|
|
||||||
> **Purpose.** A complete, implementation‑ready map of Stella Ops: product vision, all runtime components, trust boundaries, tokens/licensing, control/data flows, storage, APIs, security, scale, DevOps, and verification logic.
|
> **Purpose.** A complete, implementation‑ready map of Stella Ops: product vision, all runtime components, trust boundaries, tokens/licensing, control/data flows, storage, APIs, security, scale, DevOps, and verification logic.
|
||||||
@@ -30,28 +25,32 @@ It **absorbs** all content from `components.md` so you have a single, authoritat
|
|||||||
|
|
||||||
### 1.1 Runtime inventory (first‑party)
|
### 1.1 Runtime inventory (first‑party)
|
||||||
|
|
||||||
| Service / Tool | Container image | Core role | Scale pattern |
|
| Service / Tool | Container image | Core role | Scale pattern |
|
||||||
| ------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
| ------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||||
| **Scanner.WebService** | `stellaops/scanner-web` | Control plane for scans; catalog; SBOM composition (inventory & usage); diff; exports. | Stateless; N replicas behind LB. |
|
| **Scanner.WebService** | `stellaops/scanner-web` | Control plane for scans; catalog; SBOM composition (inventory & usage); diff; exports; **analysis‑only report runs** for Scheduler. | Stateless; N replicas behind LB. |
|
||||||
| **Scanner.Worker** | `stellaops/scanner-worker` | Runs analyzers (OS, Lang: Java/Node/Python/Go/.NET/Rust, Native ELF/PE/Mach‑O, EntryTrace); emits per‑layer SBOMs and composes image SBOMs. | Horizontal; queue‑driven; sharded by layer digest. |
|
| **Scanner.Worker** | `stellaops/scanner-worker` | Runs analyzers (OS, Lang: Java/Node/Python/Go/.NET/Rust, Native ELF/PE/Mach‑O, EntryTrace); emits per‑layer SBOMs and composes image SBOMs. | Horizontal; queue‑driven; sharded by layer digest. |
|
||||||
| **Scanner.Sbomer.BuildXPlugin** | `stellaops/sbom-indexer` | BuildKit **generator** for build‑time SBOMs as OCI **referrers**. | CI‑side; ephemeral. |
|
| **Scanner.Sbomer.BuildXPlugin** | `stellaops/sbom-indexer` | BuildKit **generator** for build‑time SBOMs as OCI **referrers**. | CI‑side; ephemeral. |
|
||||||
| **Scanner.Sbomer.DockerImage** | `stellaops/scanner-cli` | CLI‑orchestrated scanner container for post‑build scans. | Local/CI; ephemeral. |
|
| **Scanner.Sbomer.DockerImage** | `stellaops/scanner-cli` | CLI‑orchestrated scanner container for post‑build scans. | Local/CI; ephemeral. |
|
||||||
| **Concelier.WebService** | `stellaops/concelier-web` | Vulnerability ingest/normalize/merge/export (JSON + Trivy DB). | HA via Mongo locks. |
|
| **Concelier.WebService** | `stellaops/concelier-web` | Vulnerability ingest/normalize/merge/export (JSON + Trivy DB). | HA via Mongo locks. |
|
||||||
| **Excititor.WebService** | `stellaops/excititor-web` | VEX ingest/normalize/consensus; conflict retention; exports. | HA via Mongo locks. |
|
| **Excititor.WebService** | `stellaops/excititor-web` | VEX ingest/normalize/consensus; conflict retention; exports. | HA via Mongo locks. |
|
||||||
| **Policy Engine** | (in `scanner-web`) | YAML DSL evaluator (waivers, vendor preferences, KEV/EPSS, license, usage‑gating); produces **policy digest**. | In‑process; cache per digest. |
|
| **Policy Engine** | (in `scanner-web`) | YAML DSL evaluator (waivers, vendor preferences, KEV/EPSS, license, usage‑gating); produces **policy digest**. | In‑process; cache per digest. |
|
||||||
| **Signer** | `stellaops/signer` | **Hard gate:** validates entitlement + release integrity; mints signing cert (Fulcio keyless) or uses KMS; signs DSSE. | Stateless; HPA by QPS. |
|
| **Scheduler.WebService** | `stellaops/scheduler-web` | Schedules **re‑evaluation** runs; consumes Concelier/Excititor deltas; selects **impacted images** via BOM‑Index; orchestrates analysis‑only reports. | Stateless API. |
|
||||||
| **Attestor** | `stellaops/attestor` | Posts DSSE bundles to **Rekor v2**; verification endpoints. | Stateless; HPA by QPS. |
|
| **Scheduler.Worker** | `stellaops/scheduler-worker` | Executes selection and enqueues batches toward Scanner; enforces rate/limits and windows; maintains impact cursors. | Horizontal; queue‑driven. |
|
||||||
| **Authority** | `stellaops/authority` | On‑prem OIDC issuing **short‑lived OpToks** with DPoP/mTLS sender constraint. | HA behind LB. |
|
| **Notify.WebService** | `stellaops/notify-web` | Rules engine for outbound notifications; manages channels, templates, throttle/digest logic. | Stateless API. |
|
||||||
| **Zastava** (Runtime) | `stellaops/zastava` | Runtime inspector/enforcer (observer + optional Admission Webhook). | DaemonSet + Webhook. |
|
| **Notify.Worker** | `stellaops/notify-worker` | Delivers to Slack/Teams/Email/Webhooks; idempotent retries; digests. | Horizontal; per‑channel rate limits. |
|
||||||
| **Web UI** | `stellaops/ui` | Angular app for scans, diffs, policy, VEX, runtime, reports. | Stateless. |
|
| **Signer** | `stellaops/signer` | **Hard gate:** validates entitlement + release integrity; mints signing cert (Fulcio keyless) or uses KMS; signs DSSE. | Stateless; HPA by QPS. |
|
||||||
| **StellaOps.Cli** | `stellaops/cli` | CLI for init/scan/export/diff/policy/report/verify; Buildx helper. | Local/CI. |
|
| **Attestor** | `stellaops/attestor` | Posts DSSE bundles to **Rekor v2**; verification endpoints. | Stateless; HPA by QPS. |
|
||||||
|
| **Authority** | `stellaops/authority` | On‑prem OIDC issuing **short‑lived OpToks** with DPoP/mTLS sender constraint. | HA behind LB. |
|
||||||
|
| **Zastava** (Runtime) | `stellaops/zastava` | Runtime inspector/enforcer (observer + optional Admission Webhook). | DaemonSet + Webhook. |
|
||||||
|
| **Web UI** | `stellaops/ui` | Angular app for scans, diffs, policy, VEX, **Scheduler**, **Notify**, runtime, reports. | Stateless. |
|
||||||
|
| **StellaOps.Cli** | `stellaops/cli` | CLI for init/scan/export/diff/policy/report/verify; Buildx helper; **schedule** and **notify** verbs. | Local/CI. |
|
||||||
|
|
||||||
### 1.2 Third‑party (self‑hosted)
|
### 1.2 Third‑party (self‑hosted)
|
||||||
|
|
||||||
* **Fulcio** (Sigstore CA) — issues short‑lived signing certs (keyless).
|
* **Fulcio** (Sigstore CA) — issues short‑lived signing certs (keyless).
|
||||||
* **Rekor v2** (tile‑backed transparency log).
|
* **Rekor v2** (tile‑backed transparency log).
|
||||||
* **MinIO** — S3‑compatible object store with lifecycle & Object Lock.
|
* **MinIO** — S3‑compatible object store with lifecycle & Object Lock.
|
||||||
* **MongoDB** — catalog, advisories, VEX.
|
* **MongoDB** — catalog, advisories, VEX, scheduler, notify.
|
||||||
* **Queue** — Redis Streams / NATS / RabbitMQ (pluggable).
|
* **Queue** — Redis Streams / NATS / RabbitMQ (pluggable).
|
||||||
* **OCI Registry** — must support **Referrers API** (discover SBOMs/signatures).
|
* **OCI Registry** — must support **Referrers API** (discover SBOMs/signatures).
|
||||||
|
|
||||||
@@ -71,8 +70,12 @@ flowchart LR
|
|||||||
Auth[Authority (OIDC)\nOpTok (DPoP/mTLS)]
|
Auth[Authority (OIDC)\nOpTok (DPoP/mTLS)]
|
||||||
SW[Scanner.WebService]
|
SW[Scanner.WebService]
|
||||||
WK[Scanner.Worker xN]
|
WK[Scanner.Worker xN]
|
||||||
FEED[Concelier]
|
CONC[Concelier]
|
||||||
VEX[Excititor]
|
EXC[Excititor]
|
||||||
|
SCHW[Scheduler.Web]
|
||||||
|
SCH[Scheduler.Worker xN]
|
||||||
|
NOTW[Notify.Web]
|
||||||
|
NOT[Notify.Worker xN]
|
||||||
POL[Policy Engine (in Scanner.Web)]
|
POL[Policy Engine (in Scanner.Web)]
|
||||||
SGN[Signer\n(entitlement + signing)]
|
SGN[Signer\n(entitlement + signing)]
|
||||||
ATT[Attestor\n(Rekor v2 submit/verify)]
|
ATT[Attestor\n(Rekor v2 submit/verify)]
|
||||||
@@ -93,11 +96,19 @@ flowchart LR
|
|||||||
QUE --> WK
|
QUE --> WK
|
||||||
WK --> MIN
|
WK --> MIN
|
||||||
SW --> MGO
|
SW --> MGO
|
||||||
FEED --> MGO
|
CONC --> MGO
|
||||||
VEX --> MGO
|
EXC --> MGO
|
||||||
UI --> SW
|
UI --> SW
|
||||||
Z --> SW
|
Z --> SW
|
||||||
|
|
||||||
|
%% New event-driven loop
|
||||||
|
CONC -- export.delta --> SCHW
|
||||||
|
EXC -- export.delta --> SCHW
|
||||||
|
SCHW --> SCH
|
||||||
|
SCH --> SW
|
||||||
|
SW -- report.ready --> NOTW
|
||||||
|
Z -- admission/observe --> NOTW
|
||||||
|
|
||||||
SGN <--> Auth
|
SGN <--> Auth
|
||||||
SGN --> FUL
|
SGN --> FUL
|
||||||
SGN -->|mTLS| ATT
|
SGN -->|mTLS| ATT
|
||||||
@@ -106,7 +117,7 @@ flowchart LR
|
|||||||
SGN <-->|verify referrers| REG
|
SGN <-->|verify referrers| REG
|
||||||
```
|
```
|
||||||
|
|
||||||
**Trust boundaries.** Only **Signer** can sign; only **Attestor** can write to **Rekor v2**. Scanner/UI never sign.
|
**Trust boundaries.** Only **Signer** can sign; only **Attestor** can write to **Rekor v2**. Scanner/UI/Scheduler/Notify never sign.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,7 +127,7 @@ flowchart LR
|
|||||||
|
|
||||||
* **License Token (LT)** — long‑lived JWT from **Licensing Service**; used **once** to enroll the installation; never used in hot path.
|
* **License Token (LT)** — long‑lived JWT from **Licensing Service**; used **once** to enroll the installation; never used in hot path.
|
||||||
* **Proof‑of‑Entitlement (PoE)** — bound to the installation key (mTLS client cert **or** DPoP‑bound JWT with `cnf`); medium‑lived; renewable; revocable.
|
* **Proof‑of‑Entitlement (PoE)** — bound to the installation key (mTLS client cert **or** DPoP‑bound JWT with `cnf`); medium‑lived; renewable; revocable.
|
||||||
* **Operational token (OpTok)** — 2–5 min OIDC token from **Authority**, **sender‑constrained** (DPoP or mTLS). Used to authenticate to **Signer**/**Scanner.WebService**.
|
* **Operational token (OpTok)** — 2–5 min OIDC token from **Authority**, **sender‑constrained** (DPoP or mTLS). Used to authenticate to **Signer**/**Scanner.WebService**/**Scheduler.Web**/**Notify.Web**.
|
||||||
|
|
||||||
**Signer enforces both:** PoE proves entitlement; OpTok proves “who is calling now”. It also **independently verifies** the **scanner image digest** is **Stella Ops‑signed** via **Referrers + cosign** before signing anything.
|
**Signer enforces both:** PoE proves entitlement; OpTok proves “who is calling now”. It also **independently verifies** the **scanner image digest** is **Stella Ops‑signed** via **Referrers + cosign** before signing anything.
|
||||||
|
|
||||||
@@ -173,6 +184,11 @@ LS --> IA: PoE (mTLS client cert or JWT with cnf=K_inst), CRL/OCSP/introspect
|
|||||||
* Buildx **generator** runs analyzers during `docker buildx build --attest=type=sbom,generator=stellaops/sbom-indexer`, attaches SBOMs as **OCI referrers**.
|
* Buildx **generator** runs analyzers during `docker buildx build --attest=type=sbom,generator=stellaops/sbom-indexer`, attaches SBOMs as **OCI referrers**.
|
||||||
* Scanner.WebService can trust these (policy‑configurable) and **skip** re‑scan; DSSE + Rekor v2 can be done either at build time or post‑push via Signer/Attestor.
|
* Scanner.WebService can trust these (policy‑configurable) and **skip** re‑scan; DSSE + Rekor v2 can be done either at build time or post‑push via Signer/Attestor.
|
||||||
|
|
||||||
|
### 3.5 Events / integrations
|
||||||
|
|
||||||
|
* **Out:** `report.ready` (summary + verdict + Rekor UUID) → internal bus for **Notify** & UI.
|
||||||
|
* **Expose:** image‑level **BOM‑Index** metadata for **Scheduler** impact selection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4) Backend evaluation (decider)
|
## 4) Backend evaluation (decider)
|
||||||
@@ -227,6 +243,8 @@ s3://stellaops/
|
|||||||
|
|
||||||
* `artifacts` (type/format/sha/size/rekor/ttl/immutable/refCount/createdAt)
|
* `artifacts` (type/format/sha/size/rekor/ttl/immutable/refCount/createdAt)
|
||||||
* `images`, `layers`, `links`, `lifecycleRules`
|
* `images`, `layers`, `links`, `lifecycleRules`
|
||||||
|
* **Scheduler:** `schedules`, `runs`, `locks`, `impact_cursors`
|
||||||
|
* **Notify:** `rules`, `deliveries`, `channels`, `templates`
|
||||||
|
|
||||||
**Retention**
|
**Retention**
|
||||||
|
|
||||||
@@ -239,13 +257,13 @@ s3://stellaops/
|
|||||||
### 7.1 Scanner.WebService
|
### 7.1 Scanner.WebService
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/scans { imageRef|digest, force? } → { scanId }
|
POST /api/scans { imageRef|digest, force? } → { scanId }
|
||||||
GET /api/scans/{id} → { status, digests, artifacts[] }
|
GET /api/scans/{id} → { status, digests, artifacts[] }
|
||||||
GET /api/sboms/{imageDigest} ?format=cdx-json|cdx-pb|spdx-json&view=inventory|usage
|
GET /api/sboms/{imageDigest} ?format=cdx-json|cdx-pb|spdx-json&view=inventory|usage
|
||||||
GET /api/diff?old=<digest>&new=<digest> → { added[], removed[], changed[], byLayer[] }
|
GET /api/diff?old=<digest>&new=<digest> → { added[], removed[], changed[], byLayer[] }
|
||||||
POST /api/exports { imageDigest, format, view } → { artifactId, rekorUrl }
|
POST /api/exports { imageDigest, format, view } → { artifactId, rekorUrl }
|
||||||
POST /api/reports { imageDigest, policyRevision? } → { reportId, rekorUrl }
|
POST /api/reports { imageDigest, policyRevision?, vexSnapshot? } → { reportId, verdict, rekorUrl }
|
||||||
GET /api/catalog/artifacts/{id} → { size, ttl, immutable, rekor, refs }
|
GET /api/catalog/artifacts/{id} → { size, ttl, immutable, rekor, refs }
|
||||||
GET /healthz | /readyz | /metrics
|
GET /healthz | /readyz | /metrics
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -276,6 +294,25 @@ POST /license/introspect { poe } → { active, claims, exp }
|
|||||||
POST /attest/endorse { bundle } → endorsement bundle (optional)
|
POST /attest/endorse { bundle } → endorsement bundle (optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 7.6 Scheduler
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/scheduler/schedules {yaml|json} → { scheduleId }
|
||||||
|
GET /api/v1/scheduler/schedules → [ { id, nextRun, status, stats } ]
|
||||||
|
POST /api/v1/scheduler/run { id|selector } → { runId }
|
||||||
|
GET /api/v1/scheduler/runs/{id} → { status, counts, links }
|
||||||
|
GET /api/v1/scheduler/cursor → { lastConcelierExportId, lastExcititorExportId }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.7 Notify
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/notify/test { channel, target } → { delivered }
|
||||||
|
POST /api/v1/notify/rules {yaml|json} → { ruleId }
|
||||||
|
GET /api/v1/notify/rules → [ { id, match, actions, enabled } ]
|
||||||
|
GET /api/v1/notify/deliveries → [ { id, eventId, channel, status, attempts } ]
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8) Security & verifiability
|
## 8) Security & verifiability
|
||||||
@@ -283,8 +320,9 @@ POST /attest/endorse { bundle } → endorsement bundle (optio
|
|||||||
* **Sender‑constrained tokens.** All operational calls use **DPoP** (RFC 9449) or **mTLS‑bound** tokens (RFC 8705).
|
* **Sender‑constrained tokens.** All operational calls use **DPoP** (RFC 9449) or **mTLS‑bound** tokens (RFC 8705).
|
||||||
* **Entitlement.** **PoE** is mandatory; revocation honored online.
|
* **Entitlement.** **PoE** is mandatory; revocation honored online.
|
||||||
* **Release integrity.** **Signer** independently verifies **scanner image digest** via **Referrers + cosign** before signing.
|
* **Release integrity.** **Signer** independently verifies **scanner image digest** via **Referrers + cosign** before signing.
|
||||||
* **Separation of duties.** Scanner/UI cannot sign; only **Signer** can sign; only **Attestor** can write to **Rekor v2**.
|
* **Separation of duties.** Scanner/UI/Scheduler/Notify cannot sign; only **Signer** can sign; only **Attestor** can write to **Rekor v2**.
|
||||||
* **Verifiers.** Anyone can verify: DSSE signature → certificate chain to **Stella Ops Fulcio/KMS root** → **Rekor v2** inclusion.
|
* **Verifiers.** Anyone can verify: DSSE signature → certificate chain to **Stella Ops Fulcio/KMS root** → **Rekor v2** inclusion.
|
||||||
|
* **RBAC.** Roles: `scanner.admin|read`, `scheduler.admin|read`, `notify.admin|read`, `zastava.admin|read`.
|
||||||
* **Community vs Authorized.** Free/community runs throttled with no official attestations; authorized runs full speed and produce **Stella Ops‑verified** bundles.
|
* **Community vs Authorized.** Free/community runs throttled with no official attestations; authorized runs full speed and produce **Stella Ops‑verified** bundles.
|
||||||
|
|
||||||
**DSSE predicate (SBOM/report)**
|
**DSSE predicate (SBOM/report)**
|
||||||
@@ -321,6 +359,8 @@ Binary header + purl table + roaring bitmaps; optional `usedByEntrypoint` flags
|
|||||||
* Build‑time path P95 ≤ 3–5 s on warmed bases.
|
* Build‑time path P95 ≤ 3–5 s on warmed bases.
|
||||||
* Post‑build delta scan P95 ≤ 10 s for 200 MB images.
|
* Post‑build delta scan P95 ≤ 10 s for 200 MB images.
|
||||||
* Policy + VEX evaluation ≤ 500 ms for 5k components using BOM‑Index.
|
* Policy + VEX evaluation ≤ 500 ms for 5k components using BOM‑Index.
|
||||||
|
* **Event → notification** p95 ≤ **30–60 s** under nominal load.
|
||||||
|
* **Export delta → re‑evaluation verdict** p95 ≤ **5 min** for 10k impacted images.
|
||||||
* **Quotas:** license plan enforces QPS/concurrency/size; **Signer** throttles and can deny DSSE.
|
* **Quotas:** license plan enforces QPS/concurrency/size; **Signer** throttles and can deny DSSE.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -337,32 +377,37 @@ Binary header + purl table + roaring bitmaps; optional `usedByEntrypoint` flags
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
authority: { image: stellaops/authority }
|
authority: { image: stellaops/authority }
|
||||||
fulcio: { image: sigstore/fulcio }
|
fulcio: { image: sigstore/fulcio }
|
||||||
rekor: { image: sigstore/rekor-v2 }
|
rekor: { image: sigstore/rekor-v2 }
|
||||||
minio: { image: minio/minio, command: server /data --console-address ":9001" }
|
minio: { image: minio/minio, command: server /data --console-address ":9001" }
|
||||||
mongo: { image: mongo:7 }
|
mongo: { image: mongo:7 }
|
||||||
signer: { image: stellaops/signer, depends_on: [authority, fulcio] }
|
signer: { image: stellaops/signer, depends_on: [authority, fulcio] }
|
||||||
attestor: { image: stellaops/attestor, depends_on: [rekor, signer] }
|
attestor: { image: stellaops/attestor, depends_on: [rekor, signer] }
|
||||||
scanner-web:{ image: stellaops/scanner-web, depends_on: [mongo, minio, signer, attestor] }
|
scanner-web: { image: stellaops/scanner-web, depends_on: [mongo, minio, signer, attestor] }
|
||||||
scanner-worker:
|
scanner-worker: { image: stellaops/scanner-worker, deploy: { replicas: 4 }, depends_on: [scanner-web] }
|
||||||
image: stellaops/scanner-worker
|
concelier: { image: stellaops/concelier-web, depends_on: [mongo] }
|
||||||
deploy: { replicas: 4 }
|
excititor: { image: stellaops/excititor-web, depends_on: [mongo] }
|
||||||
depends_on: [scanner-web]
|
scheduler-web: { image: stellaops/scheduler-web, depends_on: [mongo] }
|
||||||
concelier: { image: stellaops/concelier-web, depends_on: [mongo] }
|
scheduler-worker:{ image: stellaops/scheduler-worker, deploy: { replicas: 2 }, depends_on: [scheduler-web] }
|
||||||
excititor: { image: stellaops/excititor-web, depends_on: [mongo] }
|
notify-web: { image: stellaops/notify-web, depends_on: [mongo] }
|
||||||
ui: { image: stellaops/ui, depends_on: [scanner-web, concelier, excititor] }
|
notify-worker: { image: stellaops/notify-worker, deploy: { replicas: 2 }, depends_on: [notify-web] }
|
||||||
|
ui: { image: stellaops/ui, depends_on: [scanner-web, concelier, excititor, scheduler-web, notify-web] }
|
||||||
```
|
```
|
||||||
|
|
||||||
* **Backups:** Mongo dumps; MinIO versioned buckets & replication; Rekor v2 DB snapshots; JWKS/Fulcio/KMS key rotation.
|
* **Backups:** Mongo dumps; MinIO versioned buckets & replication; Rekor v2 DB snapshots; JWKS/Fulcio/KMS key rotation.
|
||||||
|
* **Ops runbooks:** Scheduler catch‑up after Concelier/Excititor recovery; connector key rotation (Slack/Teams/SMTP).
|
||||||
|
* **SLOs & alerts:** lag between Concelier/Excititor export and first rescan verdict; delivery failure rates by channel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11) Observability & audit
|
## 11) Observability & audit
|
||||||
|
|
||||||
* **Metrics:** scan latency, layer cache hit %, artifact bytes, DSSE/Rekor latency, policy evaluation time, queue depth, admission decisions (Zastava).
|
* **Metrics:** scan latency, layer cache hit %, artifact bytes, DSSE/Rekor latency, policy evaluation time, queue depth, admission decisions (Zastava).
|
||||||
* **Tracing:** per‑stage spans; correlation IDs across Scanner→Signer→Attestor.
|
* **Scheduler metrics:** `scheduler.impacted_images_total`, `scheduler.jobs_enqueued_total`, `scheduler.selection_ms`, end‑to‑end p95 (event → verdict).
|
||||||
* **Audit logs:** every signing records `license_id`, `image_digest`, `policy_digest`, and Rekor UUID.
|
* **Notify metrics:** `notify.sent_total{channel}`, `notify.dropped_total{reason}`, `notify.digest_coalesced_total`, `notify.latency_ms`.
|
||||||
|
* **Tracing:** per‑stage spans; correlation IDs across Scanner→Signer→Attestor and Concelier/Excititor→Scheduler→Scanner→Notify.
|
||||||
|
* **Audit logs:** every signing records `license_id`, `image_digest`, `policy_digest`, and Rekor UUID; Scheduler records who scheduled what; Notify records where, when, and why messages were sent or deduped.
|
||||||
* **Compliance:** MinIO **Object Lock** for immutable artifacts; reproducible outputs via policy digest + SBOM digest in predicate.
|
* **Compliance:** MinIO **Object Lock** for immutable artifacts; reproducible outputs via policy digest + SBOM digest in predicate.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -373,11 +418,13 @@ services:
|
|||||||
* M2: Buildx generator certified flows; cross‑registry trust policies.
|
* M2: Buildx generator certified flows; cross‑registry trust policies.
|
||||||
* M3: Patch‑Presence plugin (signature‑based backport detection), opt‑in.
|
* M3: Patch‑Presence plugin (signature‑based backport detection), opt‑in.
|
||||||
* M3: Zastava Admission control GA with policy presets and dry‑run→enforce stages.
|
* M3: Zastava Admission control GA with policy presets and dry‑run→enforce stages.
|
||||||
|
* M3: **Scheduler GA** with export‑delta impact routing and capacity‑aware pacing.
|
||||||
|
* M3: **Notify GA** with digests, Slack/Teams/Email/Webhooks; **M4:** PagerDuty/Opsgenie connectors.
|
||||||
* Continuous: Policy UX (waiver TTLs, vendor rules), Excititor connectors expansion.
|
* Continuous: Policy UX (waiver TTLs, vendor rules), Excititor connectors expansion.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13) Canonical sequences (verification & signing)
|
## 13) Canonical sequences (verification, re‑evaluation & notify)
|
||||||
|
|
||||||
**Sign & log (OpTok + PoE, image verify, DSSE, Rekor).**
|
**Sign & log (OpTok + PoE, image verify, DSSE, Rekor).**
|
||||||
|
|
||||||
@@ -409,22 +456,62 @@ sequenceDiagram
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
**Verification (third party).**
|
**Event‑driven re‑evaluation & notify.**
|
||||||
|
|
||||||
```plantuml
|
```mermaid
|
||||||
@startuml
|
sequenceDiagram
|
||||||
actor Verifier
|
participant CONC as Concelier
|
||||||
participant "stellaops verify" as Tool
|
participant EXC as Excititor
|
||||||
database "Fulcio/KMS root" as Root
|
participant SCH as Scheduler
|
||||||
participant "Rekor v2" as R2
|
participant SC as Scanner.WebService
|
||||||
Verifier -> Tool: bundle (URL/file)
|
participant NO as Notify
|
||||||
Tool -> Tool: Verify DSSE signature
|
|
||||||
Tool -> Root: Verify cert chain to StellaOps root
|
CONC->>SCH: export.delta {changedProductKeys, exportId}
|
||||||
Tool -> R2: Verify inclusion proof / query by UUID
|
EXC ->>SCH: export.delta {changedProductKeys, exportId}
|
||||||
Tool -> Verifier: OK + claims (license_id, policy_digest, version)
|
SCH->>SCH: Impact select via BOM-Index bitmaps
|
||||||
@enduml
|
SCH->>SC: Enqueue analysis-only reports (batches)
|
||||||
|
SC-->>SCH: verdict stream (PASS/FAIL, deltas)
|
||||||
|
SCH->>NO: rescan.delta {imageDigest, newCriticals, links}
|
||||||
|
NO-->>Slack/Teams/Email/Webhook: deliver (throttle/digest rules applied)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**End of `high_level_architecture.md` (Consolidated).**
|
## 14) Minimal data shapes (Scheduler & Notify)
|
||||||
|
|
||||||
|
**Scheduler schedule (YAML via UI/CLI)**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: nightly-eu
|
||||||
|
when: "0 2 * * * Europe/Sofia"
|
||||||
|
mode: analysis-only # or content-refresh
|
||||||
|
selection:
|
||||||
|
scope: all-images # or tenant/ns/repo label selectors
|
||||||
|
onlyIf: { lastReportOlderThanDays: 7 }
|
||||||
|
notify:
|
||||||
|
onNewFindings: true
|
||||||
|
minSeverity: high
|
||||||
|
limits:
|
||||||
|
maxJobs: 5000
|
||||||
|
ratePerSecond: 50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notify rule (YAML)**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: high-critical-alerts
|
||||||
|
match:
|
||||||
|
eventKinds: ["report.ready","rescan.delta","zastava.admission"]
|
||||||
|
minSeverity: high
|
||||||
|
namespaces: ["prod-*"]
|
||||||
|
vex: { includeAcceptedJustifications: false }
|
||||||
|
actions:
|
||||||
|
- channel: slack
|
||||||
|
target: "#sec-alerts"
|
||||||
|
template: "concise"
|
||||||
|
throttle: "5m"
|
||||||
|
- channel: email
|
||||||
|
target: "soc@acme.org"
|
||||||
|
digest: "hourly"
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ src/
|
|||||||
|
|
||||||
**Language/runtime**: .NET 10 **Native AOT** for speed/startup; Linux builds use **musl** static when possible.
|
**Language/runtime**: .NET 10 **Native AOT** for speed/startup; Linux builds use **musl** static when possible.
|
||||||
|
|
||||||
|
**Plug-in verbs.** Non-core verbs (Excititor, runtime helpers, future integrations) ship as restart-time plug-ins under `plugins/cli/**` with manifest descriptors. The launcher loads plug-ins on startup; hot reloading is intentionally unsupported.
|
||||||
|
|
||||||
**OS targets**: linux‑x64/arm64, windows‑x64/arm64, macOS‑x64/arm64.
|
**OS targets**: linux‑x64/arm64, windows‑x64/arm64, macOS‑x64/arm64.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -386,4 +388,3 @@ script:
|
|||||||
* macOS: 13–15 (x64, arm64).
|
* macOS: 13–15 (x64, arm64).
|
||||||
* Windows: 10/11, Server 2019/2022 (x64, arm64).
|
* Windows: 10/11, Server 2019/2022 (x64, arm64).
|
||||||
* Docker engines: Docker Desktop, containerd‑based runners.
|
* Docker engines: Docker Desktop, containerd‑based runners.
|
||||||
|
|
||||||
|
|||||||
456
docs/ARCHITECTURE_NOTIFY.md
Normal file
456
docs/ARCHITECTURE_NOTIFY.md
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
> **Scope.** Implementation‑ready architecture for **Notify**: a rules‑driven, tenant‑aware notification service that consumes platform events (scan completed, report ready, rescan deltas, attestation logged, admission decisions, etc.), evaluates operator‑defined routing rules, renders **channel‑specific messages** (Slack/Teams/Email/Webhook), and delivers them **reliably** with idempotency, throttling, and digests. It is UI‑managed, auditable, and safe by default (no secrets leakage, no spam storms).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) Mission & boundaries
|
||||||
|
|
||||||
|
**Mission.** Convert **facts** from Stella Ops into **actionable, noise‑controlled** signals where teams already live (chat/email/webhooks), with **explainable** reasons and deep links to the UI.
|
||||||
|
|
||||||
|
**Boundaries.**
|
||||||
|
|
||||||
|
* Notify **does not make policy decisions** and **does not rescan**; it **consumes** events from Scanner/Scheduler/Vexer/Feedser/Attestor/Zastava and routes them.
|
||||||
|
* Attachments are **links** (UI/attestation pages); Notify **does not** attach SBOMs or large blobs to messages.
|
||||||
|
* Secrets for channels (Slack tokens, SMTP creds) are **referenced**, not stored raw in Mongo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Runtime shape & projects
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├─ StellaOps.Notify.WebService/ # REST: rules/channels CRUD, test send, deliveries browse
|
||||||
|
├─ StellaOps.Notify.Worker/ # consumers + evaluators + renderers + delivery workers
|
||||||
|
├─ StellaOps.Notify.Connectors.* / # channel plug-ins: Slack, Teams, Email, Webhook (v1)
|
||||||
|
│ └─ *.Tests/
|
||||||
|
├─ StellaOps.Notify.Engine/ # rules engine, templates, idempotency, digests, throttles
|
||||||
|
├─ StellaOps.Notify.Models/ # DTOs (Rule, Channel, Event, Delivery, Template)
|
||||||
|
├─ StellaOps.Notify.Storage.Mongo/ # rules, channels, deliveries, digests, locks
|
||||||
|
├─ StellaOps.Notify.Queue/ # bus client (Redis Streams/NATS JetStream)
|
||||||
|
└─ StellaOps.Notify.Tests.* # unit/integration/e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deployables**:
|
||||||
|
|
||||||
|
* **Notify.WebService** (stateless API)
|
||||||
|
* **Notify.Worker** (horizontal scale)
|
||||||
|
|
||||||
|
**Dependencies**: Authority (OpToks; DPoP/mTLS), MongoDB, Redis/NATS (bus), HTTP egress to Slack/Teams/Webhooks, SMTP relay for Email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Responsibilities
|
||||||
|
|
||||||
|
1. **Ingest** platform events from internal bus with strong ordering per key (e.g., image digest).
|
||||||
|
2. **Evaluate rules** (tenant‑scoped) with matchers: severity changes, namespaces, repos, labels, KEV flags, provider provenance (VEX), component keys, admission decisions, etc.
|
||||||
|
3. **Control noise**: **throttle**, **coalesce** (digest windows), and **dedupe** via idempotency keys.
|
||||||
|
4. **Render** channel‑specific messages using safe templates; include **evidence** and **links**.
|
||||||
|
5. **Deliver** with retries/backoff; record outcome; expose delivery history to UI.
|
||||||
|
6. **Test** paths (send test to channel targets) without touching live rules.
|
||||||
|
7. **Audit**: log who configured what, when, and why a message was sent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Event model (inputs)
|
||||||
|
|
||||||
|
Notify subscribes to the **internal event bus** (produced by services, escaped JSON; gzip allowed with caps):
|
||||||
|
|
||||||
|
* `scanner.scan.completed` — new SBOM(s) composed; artifacts ready
|
||||||
|
* `scanner.report.ready` — analysis verdict (policy+vex) available; carries deltas summary
|
||||||
|
* `scheduler.rescan.delta` — new findings after Feedser/Vexer deltas (already summarized)
|
||||||
|
* `attestor.logged` — Rekor UUID returned (sbom/report/vex export)
|
||||||
|
* `zastava.admission` — admit/deny with reasons, namespace, image digests
|
||||||
|
* `feedser.export.completed` — new export ready (rarely notified directly; usually drives Scheduler)
|
||||||
|
* `vexer.export.completed` — new consensus snapshot (ditto)
|
||||||
|
|
||||||
|
**Canonical envelope (bus → Notify.Engine):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventId": "uuid",
|
||||||
|
"kind": "scanner.report.ready",
|
||||||
|
"tenant": "tenant-01",
|
||||||
|
"ts": "2025-10-18T05:41:22Z",
|
||||||
|
"actor": "scanner-webservice",
|
||||||
|
"scope": { "namespace":"payments", "repo":"ghcr.io/acme/api", "digest":"sha256:..." },
|
||||||
|
"payload": { /* kind-specific fields, see below */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples (payload cores):**
|
||||||
|
|
||||||
|
* `scanner.report.ready`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "verdict":"fail|warn|pass",
|
||||||
|
"delta": { "newCritical":1, "newHigh":2, "kev":["CVE-2025-..."] },
|
||||||
|
"topFindings":[{"purl":"pkg:rpm/openssl","vulnId":"CVE-2025-...","severity":"critical"}],
|
||||||
|
"links":{"ui":"https://ui/...","rekor":"https://rekor/..."} }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `zastava.admission`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "decision":"deny|allow", "reasons":["unsigned image","missing SBOM"],
|
||||||
|
"images":[{"digest":"sha256:...","signed":false,"hasSbom":false}] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Rules engine — semantics
|
||||||
|
|
||||||
|
**Rule shape (simplified):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: "high-critical-alerts-prod"
|
||||||
|
enabled: true
|
||||||
|
match:
|
||||||
|
eventKinds: ["scanner.report.ready","scheduler.rescan.delta","zastava.admission"]
|
||||||
|
namespaces: ["prod-*"]
|
||||||
|
repos: ["ghcr.io/acme/*"]
|
||||||
|
minSeverity: "high" # min of new findings (delta context)
|
||||||
|
kev: true # require KEV-tagged or allow any if false
|
||||||
|
verdict: ["fail","deny"] # filter for report/admission
|
||||||
|
vex:
|
||||||
|
includeRejectedJustifications: false # notify only on accepted 'affected'
|
||||||
|
actions:
|
||||||
|
- channel: "slack:sec-alerts" # reference to Channel object
|
||||||
|
template: "concise"
|
||||||
|
throttle: "5m"
|
||||||
|
- channel: "email:soc"
|
||||||
|
digest: "hourly"
|
||||||
|
template: "detailed"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evaluation order**
|
||||||
|
|
||||||
|
1. **Tenant check** → discard if rule tenant ≠ event tenant.
|
||||||
|
2. **Kind filter** → discard early.
|
||||||
|
3. **Scope match** (namespace/repo/labels).
|
||||||
|
4. **Delta/severity gates** (if event carries `delta`).
|
||||||
|
5. **VEX gate** (drop if event’s finding is not affected under policy consensus unless rule says otherwise).
|
||||||
|
6. **Throttling/dedup** (idempotency key) — skip if suppressed.
|
||||||
|
7. **Actions** → enqueue per‑channel job(s).
|
||||||
|
|
||||||
|
**Idempotency key**: `hash(ruleId | actionId | event.kind | scope.digest | delta.hash | day-bucket)`; ensures “same alert” doesn’t fire more than once within throttle window.
|
||||||
|
|
||||||
|
**Digest windows**: maintain per action a **coalescer**:
|
||||||
|
|
||||||
|
* Window: `5m|15m|1h|1d` (configurable); coalesces events by tenant + namespace/repo or by digest group.
|
||||||
|
* Digest messages summarize top N items and counts, with safe truncation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Channels & connectors (plug‑ins)
|
||||||
|
|
||||||
|
Channel config is **two‑part**: a **Channel** record (name, type, options) and a Secret **reference** (Vault/K8s Secret). Connectors are **restart-time plug-ins** discovered on service start (same manifest convention as Concelier/Excititor) and live under `plugins/notify/<channel>/`.
|
||||||
|
|
||||||
|
**Built‑in v1:**
|
||||||
|
|
||||||
|
* **Slack**: Bot token (xoxb‑…), `chat.postMessage` + `blocks`; rate limit aware (HTTP 429).
|
||||||
|
* **Microsoft Teams**: Incoming Webhook (or Graph card later); adaptive card payloads.
|
||||||
|
* **Email (SMTP)**: TLS (STARTTLS or implicit), From/To/CC/BCC; HTML+text alt; DKIM optional.
|
||||||
|
* **Generic Webhook**: POST JSON with HMAC signature (Ed25519 or SHA‑256) in headers.
|
||||||
|
|
||||||
|
**Connector contract:** (implemented by plug-in assemblies)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface INotifyConnector {
|
||||||
|
string Type { get; } // "slack" | "teams" | "email" | "webhook" | ...
|
||||||
|
Task<DeliveryResult> SendAsync(DeliveryContext ctx, CancellationToken ct);
|
||||||
|
Task<HealthResult> HealthAsync(ChannelConfig cfg, CancellationToken ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DeliveryContext** includes **rendered content** and **raw event** for audit.
|
||||||
|
|
||||||
|
**Secrets**: `ChannelConfig.secretRef` points to Authority‑managed secret handle or K8s Secret path; workers load at send-time; plug-in manifests (`notify-plugin.json`) declare capabilities and version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Templates & rendering
|
||||||
|
|
||||||
|
**Template engine**: strongly typed, safe Handlebars‑style; no arbitrary code. Partial templates per channel. Deterministic outputs (prop order, no locale drift unless requested).
|
||||||
|
|
||||||
|
**Variables** (examples):
|
||||||
|
|
||||||
|
* `event.kind`, `event.ts`, `scope.namespace`, `scope.repo`, `scope.digest`
|
||||||
|
* `payload.verdict`, `payload.delta.newCritical`, `payload.links.ui`, `payload.links.rekor`
|
||||||
|
* `topFindings[]` with `purl`, `vulnId`, `severity`
|
||||||
|
* `policy.name`, `policy.revision` (if available)
|
||||||
|
|
||||||
|
**Helpers**:
|
||||||
|
|
||||||
|
* `severity_icon(sev)`, `link(text,url)`, `pluralize(n, "finding")`, `truncate(text, n)`, `code(text)`.
|
||||||
|
|
||||||
|
**Channel mapping**:
|
||||||
|
|
||||||
|
* Slack: title + blocks, limited to 50 blocks/3000 chars per section; long lists → link to UI.
|
||||||
|
* Teams: Adaptive Card schema 1.5; fallback text for older channels.
|
||||||
|
* Email: HTML + text; inline table of top N findings, rest behind UI link.
|
||||||
|
* Webhook: JSON with `event`, `ruleId`, `actionId`, `summary`, `links`, and raw `payload` subset.
|
||||||
|
|
||||||
|
**i18n**: template set per locale (English default; Bulgarian built‑in).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Data model (Mongo)
|
||||||
|
|
||||||
|
**Database**: `notify`
|
||||||
|
|
||||||
|
* `rules`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ _id, tenantId, name, enabled, match, actions, createdBy, updatedBy, createdAt, updatedAt }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `channels`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ _id, tenantId, name:"slack:sec-alerts", type:"slack",
|
||||||
|
config:{ webhookUrl?:"", channel:"#sec-alerts", workspace?: "...", secretRef:"ref://..." },
|
||||||
|
createdAt, updatedAt }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `deliveries`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ _id, tenantId, ruleId, actionId, eventId, kind, scope, status:"sent|failed|throttled|digested|dropped",
|
||||||
|
attempts:[{ts, status, code, reason}],
|
||||||
|
rendered:{ title, body, target }, // redacted for PII; body hash stored
|
||||||
|
sentAt, lastError? }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `digests`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ _id, tenantId, actionKey, window:"hourly", openedAt, items:[{eventId, scope, delta}], status:"open|flushed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `throttles`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ key:"idem:<hash>", ttlAt } // short-lived, also cached in Redis
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes**: rules by `{tenantId, enabled}`, deliveries by `{tenantId, sentAt desc}`, digests by `{tenantId, actionKey}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) External APIs (WebService)
|
||||||
|
|
||||||
|
Base path: `/api/v1/notify` (Authority OpToks; scopes: `notify.admin` for write, `notify.read` for view).
|
||||||
|
|
||||||
|
* **Channels**
|
||||||
|
|
||||||
|
* `POST /channels` | `GET /channels` | `GET /channels/{id}` | `PATCH /channels/{id}` | `DELETE /channels/{id}`
|
||||||
|
* `POST /channels/{id}/test` → send sample message (no rule evaluation)
|
||||||
|
* `GET /channels/{id}/health` → connector self‑check
|
||||||
|
|
||||||
|
* **Rules**
|
||||||
|
|
||||||
|
* `POST /rules` | `GET /rules` | `GET /rules/{id}` | `PATCH /rules/{id}` | `DELETE /rules/{id}`
|
||||||
|
* `POST /rules/{id}/test` → dry‑run rule against a **sample event** (no delivery unless `--send`)
|
||||||
|
|
||||||
|
* **Deliveries**
|
||||||
|
|
||||||
|
* `GET /deliveries?tenant=...&since=...` → list
|
||||||
|
* `GET /deliveries/{id}` → detail (redacted body + metadata)
|
||||||
|
* `POST /deliveries/{id}/retry` → force retry (admin)
|
||||||
|
|
||||||
|
* **Admin**
|
||||||
|
|
||||||
|
* `GET /stats` (per tenant counts, last hour/day)
|
||||||
|
* `GET /healthz|readyz` (liveness)
|
||||||
|
|
||||||
|
**Ingestion**: workers do **not** expose public ingestion; they **subscribe** to the internal bus. (Optional `/events/test` for integration testing, admin‑only.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Delivery pipeline (worker)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Event bus] → [Ingestor] → [RuleMatcher] → [Throttle/Dedupe] → [DigestCoalescer] → [Renderer] → [Connector] → [Result]
|
||||||
|
└────────→ [DeliveryStore]
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Ingestor**: N consumers with per‑key ordering (key = tenant|digest|namespace).
|
||||||
|
* **RuleMatcher**: loads active rules snapshot for tenant into memory; vectorized predicate check.
|
||||||
|
* **Throttle/Dedupe**: consult Redis + Mongo `throttles`; if hit → record `status=throttled`.
|
||||||
|
* **DigestCoalescer**: append to open digest window or flush when timer expires.
|
||||||
|
* **Renderer**: select template (channel+locale), inject variables, enforce length limits, compute `bodyHash`.
|
||||||
|
* **Connector**: send; handle provider‑specific rate limits and backoffs; `maxAttempts` with exponential jitter; overflow → DLQ (dead‑letter topic) + UI surfacing.
|
||||||
|
|
||||||
|
**Idempotency**: per action **idempotency key** stored in Redis (TTL = `throttle window` or `digest window`). Connectors also respect **provider** idempotency where available (e.g., Slack `client_msg_id`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Reliability & rate controls
|
||||||
|
|
||||||
|
* **Per‑tenant** RPM caps (default 600/min) + **per‑channel** concurrency (Slack 1–4, Teams 1–2, Email 8–32 based on relay).
|
||||||
|
* **Backoff** map: Slack 429 → respect `Retry‑After`; SMTP 4xx → retry; 5xx → retry with jitter; permanent rejects → drop with status recorded.
|
||||||
|
* **DLQ**: NATS/Redis stream `notify.dlq` with `{event, rule, action, error}` for operator inspection; UI shows DLQ items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Security & privacy
|
||||||
|
|
||||||
|
* **AuthZ**: all APIs require **Authority** OpToks; actions scoped by tenant.
|
||||||
|
* **Secrets**: `secretRef` only; Notify fetches just‑in‑time from Authority Secret proxy or K8s Secret (mounted). No plaintext secrets in Mongo.
|
||||||
|
* **Egress TLS**: validate SSL; pin domains per channel config; optional CA bundle override for on‑prem SMTP.
|
||||||
|
* **Webhook signing**: HMAC or Ed25519 signatures in `X-StellaOps-Signature` + replay‑window timestamp; include canonical body hash in header.
|
||||||
|
* **Redaction**: deliveries store **hashes** of bodies, not full payloads for chat/email to minimize PII retention (configurable).
|
||||||
|
* **Quiet hours**: per tenant (e.g., 22:00–06:00) route high‑sev only; defer others to digests.
|
||||||
|
* **Loop prevention**: Webhook target allowlist + event origin tags; do not ingest own webhooks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Observability (Prometheus + OTEL)
|
||||||
|
|
||||||
|
* `notify.events_consumed_total{kind}`
|
||||||
|
* `notify.rules_matched_total{ruleId}`
|
||||||
|
* `notify.throttled_total{reason}`
|
||||||
|
* `notify.digest_coalesced_total{window}`
|
||||||
|
* `notify.sent_total{channel}` / `notify.failed_total{channel,code}`
|
||||||
|
* `notify.delivery_latency_seconds{channel}` (end‑to‑end)
|
||||||
|
* **Tracing**: spans `ingest`, `match`, `render`, `send`; correlation id = `eventId`.
|
||||||
|
|
||||||
|
**SLO targets**
|
||||||
|
|
||||||
|
* Event→delivery p95 **≤ 30–60 s** under nominal load.
|
||||||
|
* Failure rate p95 **< 0.5%** per hour (excluding provider outages).
|
||||||
|
* Duplicate rate **≈ 0** (idempotency working).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Configuration (YAML)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notify:
|
||||||
|
authority:
|
||||||
|
issuer: "https://authority.internal"
|
||||||
|
require: "dpop" # or "mtls"
|
||||||
|
bus:
|
||||||
|
kind: "redis" # or "nats"
|
||||||
|
streams:
|
||||||
|
- "scanner.events"
|
||||||
|
- "scheduler.events"
|
||||||
|
- "attestor.events"
|
||||||
|
- "zastava.events"
|
||||||
|
mongo:
|
||||||
|
uri: "mongodb://mongo/notify"
|
||||||
|
limits:
|
||||||
|
perTenantRpm: 600
|
||||||
|
perChannel:
|
||||||
|
slack: { concurrency: 2 }
|
||||||
|
teams: { concurrency: 1 }
|
||||||
|
email: { concurrency: 8 }
|
||||||
|
webhook: { concurrency: 8 }
|
||||||
|
digests:
|
||||||
|
defaultWindow: "1h"
|
||||||
|
maxItems: 100
|
||||||
|
quietHours:
|
||||||
|
enabled: true
|
||||||
|
window: "22:00-06:00"
|
||||||
|
minSeverity: "critical"
|
||||||
|
webhooks:
|
||||||
|
sign:
|
||||||
|
method: "ed25519" # or "hmac-sha256"
|
||||||
|
keyRef: "ref://notify/webhook-sign-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14) UI touch‑points
|
||||||
|
|
||||||
|
* **Notifications → Channels**: add Slack/Teams/Email/Webhook; run **health**; rotate secrets.
|
||||||
|
* **Notifications → Rules**: create/edit YAML rules with linting; test with sample events; see match rate.
|
||||||
|
* **Notifications → Deliveries**: timeline with filters (status, channel, rule); inspect last error; retry.
|
||||||
|
* **Digest preview**: shows current window contents and when it will flush.
|
||||||
|
* **Quiet hours**: configure per tenant; show overrides.
|
||||||
|
* **DLQ**: browse dead‑letters; requeue after fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15) Failure modes & responses
|
||||||
|
|
||||||
|
| Condition | Behavior |
|
||||||
|
| ----------------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| Slack 429 / Teams 429 | Respect `Retry‑After`, backoff with jitter, reduce concurrency |
|
||||||
|
| SMTP transient 4xx | Retry up to `maxAttempts`; escalate to DLQ on exhaust |
|
||||||
|
| Invalid channel secret | Mark channel unhealthy; suppress sends; surface in UI |
|
||||||
|
| Rule explosion (matches everything) | Safety valve: per‑tenant RPM caps; auto‑pause rule after X drops; UI alert |
|
||||||
|
| Bus outage | Buffer to local queue (bounded); resume consuming when healthy |
|
||||||
|
| Mongo slowness | Fall back to Redis throttles; batch write deliveries; shed low‑priority notifications |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16) Testing matrix
|
||||||
|
|
||||||
|
* **Unit**: matchers, throttle math, digest coalescing, idempotency keys, template rendering edge cases.
|
||||||
|
* **Connectors**: provider‑level rate limits, payload size truncation, error mapping.
|
||||||
|
* **Integration**: synthetic event storm (10k/min), ensure p95 latency & duplicate rate.
|
||||||
|
* **Security**: DPoP/mTLS on APIs; secretRef resolution; webhook signing & replay windows.
|
||||||
|
* **i18n**: localized templates render deterministically.
|
||||||
|
* **Chaos**: Slack/Teams API flaps; SMTP greylisting; Redis hiccups; ensure graceful degradation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17) Sequences (representative)
|
||||||
|
|
||||||
|
**A) New criticals after Feedser delta (Slack immediate + Email hourly digest)**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant SCH as Scheduler
|
||||||
|
participant NO as Notify.Worker
|
||||||
|
participant SL as Slack
|
||||||
|
participant SMTP as Email
|
||||||
|
|
||||||
|
SCH->>NO: bus event scheduler.rescan.delta { newCritical:1, digest:sha256:... }
|
||||||
|
NO->>NO: match rules (Slack immediate; Email hourly digest)
|
||||||
|
NO->>SL: chat.postMessage (concise)
|
||||||
|
SL-->>NO: 200 OK
|
||||||
|
NO->>NO: append to digest window (email:soc)
|
||||||
|
Note over NO: At window close → render digest email
|
||||||
|
NO->>SMTP: send email (detailed digest)
|
||||||
|
SMTP-->>NO: 250 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
**B) Admission deny (Teams card + Webhook)**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant ZA as Zastava
|
||||||
|
participant NO as Notify.Worker
|
||||||
|
participant TE as Teams
|
||||||
|
participant WH as Webhook
|
||||||
|
|
||||||
|
ZA->>NO: bus event zastava.admission { decision: "deny", reasons: [...] }
|
||||||
|
NO->>TE: POST adaptive card
|
||||||
|
TE-->>NO: 200 OK
|
||||||
|
NO->>WH: POST JSON (signed)
|
||||||
|
WH-->>NO: 2xx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18) Implementation notes
|
||||||
|
|
||||||
|
* **Language**: .NET 10; minimal API; `System.Text.Json` with canonical writer for body hashing; Channels for pipelines.
|
||||||
|
* **Bus**: Redis Streams (**XGROUP** consumers) or NATS JetStream for at‑least‑once with ack; per‑tenant consumer groups to localize backpressure.
|
||||||
|
* **Templates**: compile and cache per rule+channel+locale; version with rule `updatedAt` to invalidate.
|
||||||
|
* **Rules**: store raw YAML + parsed AST; validate with schema + static checks (e.g., nonsensical combos).
|
||||||
|
* **Secrets**: pluggable secret resolver (Authority Secret proxy, K8s, Vault).
|
||||||
|
* **Rate limiting**: `System.Threading.RateLimiting` + per‑connector adapters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19) Roadmap (post‑v1)
|
||||||
|
|
||||||
|
* **PagerDuty/Opsgenie** connectors; **Jira** ticket creation.
|
||||||
|
* **User inbox** (in‑app notifications) + mobile push via webhook relay.
|
||||||
|
* **Anomaly suppression**: auto‑pause noisy rules with hints (learned thresholds).
|
||||||
|
* **Graph rules**: “only notify if *not_affected → affected* transition at consensus layer”.
|
||||||
|
* **Label enrichment**: pluggable taggers (business criticality, data classification) to refine matchers.
|
||||||
@@ -40,6 +40,8 @@ src/
|
|||||||
└─ StellaOps.Scanner.Sbomer.DockerImage/ # CLI‑driven scanner container
|
└─ StellaOps.Scanner.Sbomer.DockerImage/ # CLI‑driven scanner container
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Analyzer assemblies and buildx generators are packaged as **restart-time plug-ins** under `plugins/scanner/**` with manifests; services must restart to activate new plug-ins.
|
||||||
|
|
||||||
**Runtime form‑factor:** two deployables
|
**Runtime form‑factor:** two deployables
|
||||||
|
|
||||||
* **Scanner.WebService** (stateless REST)
|
* **Scanner.WebService** (stateless REST)
|
||||||
@@ -410,4 +412,3 @@ vector<string> purls
|
|||||||
map<purlIndex, roaring_bitmap> components
|
map<purlIndex, roaring_bitmap> components
|
||||||
optional map<purlIndex, roaring_bitmap> usedByEntrypoint
|
optional map<purlIndex, roaring_bitmap> usedByEntrypoint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
424
docs/ARCHITECTURE_SCHEDULER.md
Normal file
424
docs/ARCHITECTURE_SCHEDULER.md
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
# component_architecture_scheduler.md — **Stella Ops Scheduler** (2025Q4)
|
||||||
|
|
||||||
|
> **Scope.** Implementation‑ready architecture for **Scheduler**: a service that (1) **re‑evaluates** already‑cataloged images when intel changes (Feedser/Vexer/policy), (2) orchestrates **nightly** and **ad‑hoc** runs, (3) targets only the **impacted** images using the BOM‑Index, and (4) emits **report‑ready** events that downstream **Notify** fans out. Default mode is **analysis‑only** (no image pull); optional **content‑refresh** can be enabled per schedule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) Mission & boundaries
|
||||||
|
|
||||||
|
**Mission.** Keep scan results **current** without rescanning the world. When new advisories or VEX claims land, **pinpoint** affected images and ask the backend to recompute **verdicts** against the **existing SBOMs**. Surface only **meaningful deltas** to humans and ticket queues.
|
||||||
|
|
||||||
|
**Boundaries.**
|
||||||
|
|
||||||
|
* Scheduler **does not** compute SBOMs and **does not** sign. It calls Scanner/WebService’s **/reports (analysis‑only)** endpoint and lets the backend (Policy + Vexer + Feedser) decide PASS/FAIL.
|
||||||
|
* Scheduler **may** ask Scanner to **content‑refresh** selected targets (e.g., mutable tags) but the default is **no** image pull.
|
||||||
|
* Notifications are **not** sent directly; Scheduler emits events consumed by **Notify**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Runtime shape & projects
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├─ StellaOps.Scheduler.WebService/ # REST (schedules CRUD, runs, admin)
|
||||||
|
├─ StellaOps.Scheduler.Worker/ # planners + runners (N replicas)
|
||||||
|
├─ StellaOps.Scheduler.ImpactIndex/ # purl→images inverted index (roaring bitmaps)
|
||||||
|
├─ StellaOps.Scheduler.Models/ # DTOs (Schedule, Run, ImpactSet, Deltas)
|
||||||
|
├─ StellaOps.Scheduler.Storage.Mongo/ # schedules, runs, cursors, locks
|
||||||
|
├─ StellaOps.Scheduler.Queue/ # Redis Streams / NATS abstraction
|
||||||
|
├─ StellaOps.Scheduler.Tests.* # unit/integration/e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deployables**:
|
||||||
|
|
||||||
|
* **Scheduler.WebService** (stateless)
|
||||||
|
* **Scheduler.Worker** (scale‑out; planners + executors)
|
||||||
|
|
||||||
|
**Dependencies**: Authority (OpTok + DPoP/mTLS), Scanner.WebService, Feedser, Vexer, MongoDB, Redis/NATS, (optional) Notify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Core responsibilities
|
||||||
|
|
||||||
|
1. **Time‑based** runs: cron windows per tenant/timezone (e.g., “02:00 Europe/Sofia”).
|
||||||
|
2. **Event‑driven** runs: react to **Feedser export** and **Vexer export** deltas (changed product keys / advisories / claims).
|
||||||
|
3. **Impact targeting**: map changes to **image sets** using a **global inverted index** built from Scanner’s per‑image **BOM‑Index** sidecars.
|
||||||
|
4. **Run planning**: shard, pace, and rate‑limit jobs to avoid thundering herds.
|
||||||
|
5. **Execution**: call Scanner **/reports (analysis‑only)** or **/scans (content‑refresh)**; aggregate **delta** results.
|
||||||
|
6. **Events**: publish `rescan.delta` and `report.ready` summaries for **Notify** & **UI**.
|
||||||
|
7. **Control plane**: CRUD schedules, **pause/resume**, dry‑run previews, audit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Data model (Mongo)
|
||||||
|
|
||||||
|
**Database**: `scheduler`
|
||||||
|
|
||||||
|
* `schedules`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ _id, tenantId, name, enabled, whenCron, timezone,
|
||||||
|
mode: "analysis-only" | "content-refresh",
|
||||||
|
selection: { scope: "all-images" | "by-namespace" | "by-repo" | "by-digest" | "by-labels",
|
||||||
|
includeTags?: ["prod-*"], digests?: [sha256...], resolvesTags?: bool },
|
||||||
|
onlyIf: { lastReportOlderThanDays?: int, policyRevision?: string },
|
||||||
|
notify: { onNewFindings: bool, minSeverity: "low|medium|high|critical", includeKEV: bool },
|
||||||
|
limits: { maxJobs?: int, ratePerSecond?: int, parallelism?: int },
|
||||||
|
createdAt, updatedAt, createdBy, updatedBy }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `runs`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ _id, scheduleId?, tenantId, trigger: "cron|feedser|vexer|manual",
|
||||||
|
reason?: { feedserExportId?, vexerExportId?, cursor? },
|
||||||
|
state: "planning|queued|running|completed|error|cancelled",
|
||||||
|
stats: { candidates: int, deduped: int, queued: int, completed: int, deltas: int, newCriticals: int },
|
||||||
|
startedAt, finishedAt, error? }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `impact_cursors`
|
||||||
|
|
||||||
|
```
|
||||||
|
{ _id: tenantId, feedserLastExportId, vexerLastExportId, updatedAt }
|
||||||
|
```
|
||||||
|
|
||||||
|
* `locks` (singleton schedulers, run leases)
|
||||||
|
|
||||||
|
* `audit` (CRUD actions, run outcomes)
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
|
||||||
|
* `schedules` on `{tenantId, enabled}`, `{whenCron}`.
|
||||||
|
* `runs` on `{tenantId, startedAt desc}`, `{state}`.
|
||||||
|
* TTL optional for completed runs (e.g., 180 days).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) ImpactIndex (global inverted index)
|
||||||
|
|
||||||
|
Goal: translate **change keys** → **image sets** in **milliseconds**.
|
||||||
|
|
||||||
|
**Source**: Scanner produces per‑image **BOM‑Index** sidecars (purls, and `usedByEntrypoint` bitmaps). Scheduler ingests/refreshes them to build a **global** index.
|
||||||
|
|
||||||
|
**Representation**:
|
||||||
|
|
||||||
|
* Assign **image IDs** (dense ints) to catalog images.
|
||||||
|
* Keep **Roaring Bitmaps**:
|
||||||
|
|
||||||
|
* `Contains[purl] → bitmap(imageIds)`
|
||||||
|
* `UsedBy[purl] → bitmap(imageIds)` (subset of Contains)
|
||||||
|
* Optionally keep **Owner maps**: `{imageId → {tenantId, namespaces[], repos[]}}` for selection filters.
|
||||||
|
* Persist in RocksDB/LMDB or Redis‑modules; cache hot shards in memory; snapshot to Mongo for cold start.
|
||||||
|
|
||||||
|
**Update paths**:
|
||||||
|
|
||||||
|
* On new/updated image SBOM: **merge** per‑image set into global maps.
|
||||||
|
* On image remove/expiry: **clear** id from bitmaps.
|
||||||
|
|
||||||
|
**API (internal)**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
IImpactIndex {
|
||||||
|
ImpactSet ResolveByPurls(IEnumerable<string> purls, bool usageOnly, Selector sel);
|
||||||
|
ImpactSet ResolveByVulns(IEnumerable<string> vulnIds, bool usageOnly, Selector sel); // optional (vuln->purl precomputed by Feedser)
|
||||||
|
ImpactSet ResolveAll(Selector sel); // for nightly
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selector filters**: tenant, namespaces, repos, labels, digest allowlists, `includeTags` patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) External interfaces (REST)
|
||||||
|
|
||||||
|
Base path: `/api/v1/scheduler` (Authority OpToks; scopes: `scheduler.read`, `scheduler.admin`).
|
||||||
|
|
||||||
|
### 5.1 Schedules CRUD
|
||||||
|
|
||||||
|
* `POST /schedules` → create
|
||||||
|
* `GET /schedules` → list (filter by tenant)
|
||||||
|
* `GET /schedules/{id}` → details + next run
|
||||||
|
* `PATCH /schedules/{id}` → pause/resume/update
|
||||||
|
* `DELETE /schedules/{id}` → delete (soft delete, optional)
|
||||||
|
|
||||||
|
### 5.2 Run control & introspection
|
||||||
|
|
||||||
|
* `POST /run` — ad‑hoc run
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "mode": "analysis-only|content-refresh", "selection": {...}, "reason": "manual" }
|
||||||
|
```
|
||||||
|
* `GET /runs` — list with paging
|
||||||
|
* `GET /runs/{id}` — status, stats, links to deltas
|
||||||
|
* `POST /runs/{id}/cancel` — best‑effort cancel
|
||||||
|
|
||||||
|
### 5.3 Previews (dry‑run)
|
||||||
|
|
||||||
|
* `POST /preview/impact` — returns **candidate count** and a small sample of impacted digests for given change keys or selection.
|
||||||
|
|
||||||
|
### 5.4 Event webhooks (optional push from Feedser/Vexer)
|
||||||
|
|
||||||
|
* `POST /events/feedser-export`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "exportId":"...", "changedProductKeys":["pkg:rpm/openssl", ...], "kev": ["CVE-..."], "window": { "from":"...","to":"..." } }
|
||||||
|
```
|
||||||
|
* `POST /events/vexer-export`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "exportId":"...", "changedClaims":[ { "productKey":"pkg:deb/...", "vulnId":"CVE-...", "status":"not_affected→affected"} ], ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security**: webhook requires **mTLS** or an **HMAC** `X-Scheduler-Signature` (Ed25519 / SHA‑256) plus Authority token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Planner → Runner pipeline
|
||||||
|
|
||||||
|
### 6.1 Planning algorithm (event‑driven)
|
||||||
|
|
||||||
|
```
|
||||||
|
On Export Event (Feedser/Vexer):
|
||||||
|
keys = Normalize(change payload) # productKeys or vulnIds→productKeys
|
||||||
|
usageOnly = schedule/policy hint? # default true
|
||||||
|
sel = Selector for tenant/scope from schedules subscribed to events
|
||||||
|
|
||||||
|
impacted = ImpactIndex.ResolveByPurls(keys, usageOnly, sel)
|
||||||
|
impacted = ApplyOwnerFilters(impacted, sel) # namespaces/repos/labels
|
||||||
|
impacted = DeduplicateByDigest(impacted)
|
||||||
|
impacted = EnforceLimits(impacted, limits.maxJobs)
|
||||||
|
shards = Shard(impacted, byHashPrefix, n=limits.parallelism)
|
||||||
|
|
||||||
|
For each shard:
|
||||||
|
Enqueue RunSegment (runId, shard, rate=limits.ratePerSecond)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fairness & pacing**
|
||||||
|
|
||||||
|
* Use **leaky bucket** per tenant and per registry host.
|
||||||
|
* Prioritize **KEV‑tagged** and **critical** first if oversubscribed.
|
||||||
|
|
||||||
|
### 6.2 Nightly planning
|
||||||
|
|
||||||
|
```
|
||||||
|
At cron tick:
|
||||||
|
sel = resolve selection
|
||||||
|
candidates = ImpactIndex.ResolveAll(sel)
|
||||||
|
if lastReportOlderThanDays present → filter by report age (via Scanner catalog)
|
||||||
|
shard & enqueue as above
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Execution (Runner)
|
||||||
|
|
||||||
|
* Pop **RunSegment** job → for each image digest:
|
||||||
|
|
||||||
|
* **analysis‑only**: `POST scanner/reports { imageDigest, policyRevision? }`
|
||||||
|
* **content‑refresh**: resolve tag→digest if needed; `POST scanner/scans { imageRef, attest? false }` then `POST /reports`
|
||||||
|
* Collect **delta**: `newFindings`, `newCriticals`/`highs`, `links` (UI deep link, Rekor if present).
|
||||||
|
* Persist per‑image outcome in `runs.{id}.stats` (incremental counters).
|
||||||
|
* Emit `scheduler.rescan.delta` events to **Notify** only when **delta > 0** and matches severity rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Event model (outbound)
|
||||||
|
|
||||||
|
**Topic**: `rescan.delta` (internal bus → Notify; UI subscribes via backend).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant": "tenant-01",
|
||||||
|
"runId": "324af…",
|
||||||
|
"imageDigest": "sha256:…",
|
||||||
|
"newCriticals": 1,
|
||||||
|
"newHigh": 2,
|
||||||
|
"kevHits": ["CVE-2025-..."],
|
||||||
|
"topFindings": [
|
||||||
|
{ "purl":"pkg:rpm/openssl@3.0.12-...","vulnId":"CVE-2025-...","severity":"critical","link":"https://ui/scans/..." }
|
||||||
|
],
|
||||||
|
"reportUrl": "https://ui/.../scans/sha256:.../report",
|
||||||
|
"attestation": { "uuid":"rekor-uuid", "verified": true },
|
||||||
|
"ts": "2025-10-18T03:12:45Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Also**: `report.ready` for “no‑change” summaries (digest + zero delta), which Notify can ignore by rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Security posture
|
||||||
|
|
||||||
|
* **AuthN/Z**: Authority OpToks with `aud=scheduler`; DPoP (preferred) or mTLS.
|
||||||
|
* **Multi‑tenant**: every schedule, run, and event carries `tenantId`; ImpactIndex filters by tenant‑visible images.
|
||||||
|
* **Webhook** callers (Feedser/Vexer) present **mTLS** or **HMAC** and Authority token.
|
||||||
|
* **Input hardening**: size caps on changed key lists; reject >100k keys per event; compress (zstd/gzip) allowed with limits.
|
||||||
|
* **No secrets** in logs; redact tokens and signatures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Observability & SLOs
|
||||||
|
|
||||||
|
**Metrics (Prometheus)**
|
||||||
|
|
||||||
|
* `scheduler.events_total{source, result}`
|
||||||
|
* `scheduler.impact_resolve_seconds{quantile}`
|
||||||
|
* `scheduler.images_selected_total{mode}`
|
||||||
|
* `scheduler.jobs_enqueued_total{mode}`
|
||||||
|
* `scheduler.run_latency_seconds{quantile}` // event → first verdict
|
||||||
|
* `scheduler.delta_images_total{severity}`
|
||||||
|
* `scheduler.rate_limited_total{reason}`
|
||||||
|
|
||||||
|
**Targets**
|
||||||
|
|
||||||
|
* Resolve 10k changed keys → impacted set in **<300 ms** (hot cache).
|
||||||
|
* Event → first rescan verdict in **≤60 s** (p95).
|
||||||
|
* Nightly coverage 50k images in **≤10 min** with 10 workers (analysis‑only).
|
||||||
|
|
||||||
|
**Tracing** (OTEL): spans `plan`, `resolve`, `enqueue`, `report_call`, `persist`, `emit`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Configuration (YAML)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scheduler:
|
||||||
|
authority:
|
||||||
|
issuer: "https://authority.internal"
|
||||||
|
require: "dpop" # or "mtls"
|
||||||
|
queue:
|
||||||
|
kind: "redis" # or "nats"
|
||||||
|
url: "redis://redis:6379/4"
|
||||||
|
mongo:
|
||||||
|
uri: "mongodb://mongo/scheduler"
|
||||||
|
impactIndex:
|
||||||
|
storage: "rocksdb" # "rocksdb" | "redis" | "memory"
|
||||||
|
warmOnStart: true
|
||||||
|
usageOnlyDefault: true
|
||||||
|
limits:
|
||||||
|
defaultRatePerSecond: 50
|
||||||
|
defaultParallelism: 8
|
||||||
|
maxJobsPerRun: 50000
|
||||||
|
integrates:
|
||||||
|
scannerUrl: "https://scanner-web.internal"
|
||||||
|
feedserWebhook: true
|
||||||
|
vexerWebhook: true
|
||||||
|
notifications:
|
||||||
|
emitBus: "internal" # deliver to Notify via internal bus
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) UI touch‑points
|
||||||
|
|
||||||
|
* **Schedules** page: CRUD, enable/pause, next run, last run stats, mode (analysis/content), selector preview.
|
||||||
|
* **Runs** page: timeline; heat‑map of deltas; drill‑down to affected images.
|
||||||
|
* **Dry‑run preview** modal: “This Feedser export touches ~3,214 images; projected deltas: ~420 (34 KEV).”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Failure modes & degradations
|
||||||
|
|
||||||
|
| Condition | Behavior |
|
||||||
|
| ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||||
|
| ImpactIndex cold / incomplete | Fall back to **All** selection for nightly; for events, cap to KEV+critical until warmed |
|
||||||
|
| Feedser/Vexer webhook storm | Coalesce by exportId; debounce 30–60 s; keep last |
|
||||||
|
| Scanner under load (429) | Backoff with jitter; respect per‑tenant/leaky bucket |
|
||||||
|
| Oversubscription (too many impacted) | Prioritize KEV/critical first; spillover to next window; UI banner shows backlog |
|
||||||
|
| Notify down | Buffer outbound events in queue (TTL 24h) |
|
||||||
|
| Mongo slow | Cut batch sizes; sample‑log; alert ops; don’t drop runs unless critical |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Testing matrix
|
||||||
|
|
||||||
|
* **ImpactIndex**: correctness (purl→image sets), performance, persistence after restart, memory pressure with 1M purls.
|
||||||
|
* **Planner**: dedupe, shard, fairness, limit enforcement, KEV prioritization.
|
||||||
|
* **Runner**: parallel report calls, error backoff, partial failures, idempotency.
|
||||||
|
* **End‑to‑end**: Feedser export → deltas visible in UI in ≤60 s.
|
||||||
|
* **Security**: webhook auth (mTLS/HMAC), DPoP nonce dance, tenant isolation.
|
||||||
|
* **Chaos**: drop scanner availability; simulate registry throttles (content‑refresh mode).
|
||||||
|
* **Nightly**: cron tick correctness across timezones and DST.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14) Implementation notes
|
||||||
|
|
||||||
|
* **Language**: .NET 10 minimal API; Channels‑based pipeline; `System.Threading.RateLimiting`.
|
||||||
|
* **Bitmaps**: Roaring via `RoaringBitmap` bindings; memory‑map large shards if RocksDB used.
|
||||||
|
* **Cron**: Quartz‑style parser with timezone support; clock skew tolerated ±60 s.
|
||||||
|
* **Dry‑run**: use ImpactIndex only; never call scanner.
|
||||||
|
* **Idempotency**: run segments carry deterministic keys; retries safe.
|
||||||
|
* **Backpressure**: per‑tenant buckets; per‑host registry budgets respected when content‑refresh enabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15) Sequences (representative)
|
||||||
|
|
||||||
|
**A) Event‑driven rescan (Feedser delta)**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant FE as Feedser
|
||||||
|
participant SCH as Scheduler.Worker
|
||||||
|
participant IDX as ImpactIndex
|
||||||
|
participant SC as Scanner.WebService
|
||||||
|
participant NO as Notify
|
||||||
|
|
||||||
|
FE->>SCH: POST /events/feedser-export {exportId, changedProductKeys}
|
||||||
|
SCH->>IDX: ResolveByPurls(keys, usageOnly=true, sel)
|
||||||
|
IDX-->>SCH: bitmap(imageIds) → digests list
|
||||||
|
SCH->>SC: POST /reports {imageDigest} (batch/sequenced)
|
||||||
|
SC-->>SCH: report deltas (new criticals/highs)
|
||||||
|
alt delta>0
|
||||||
|
SCH->>NO: rescan.delta {digest, newCriticals, links}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**B) Nightly rescan**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant CRON as Cron
|
||||||
|
participant SCH as Scheduler.Worker
|
||||||
|
participant IDX as ImpactIndex
|
||||||
|
participant SC as Scanner.WebService
|
||||||
|
|
||||||
|
CRON->>SCH: tick (02:00 Europe/Sofia)
|
||||||
|
SCH->>IDX: ResolveAll(selector)
|
||||||
|
IDX-->>SCH: candidates
|
||||||
|
SCH->>SC: POST /reports {digest} (paced)
|
||||||
|
SC-->>SCH: results
|
||||||
|
SCH-->>SCH: aggregate, store run stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**C) Content‑refresh (tag followers)**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
participant SCH as Scheduler
|
||||||
|
participant SC as Scanner
|
||||||
|
SCH->>SC: resolve tag→digest (if changed)
|
||||||
|
alt digest changed
|
||||||
|
SCH->>SC: POST /scans {imageRef} # new SBOM
|
||||||
|
SC-->>SCH: scan complete (artifacts)
|
||||||
|
SCH->>SC: POST /reports {imageDigest}
|
||||||
|
else unchanged
|
||||||
|
SCH->>SC: POST /reports {imageDigest} # analysis-only
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16) Roadmap
|
||||||
|
|
||||||
|
* **Vuln‑centric impact**: pre‑join vuln→purl→images to rank by **KEV** and **exploited‑in‑the‑wild** signals.
|
||||||
|
* **Policy diff preview**: when a staged policy changes, show projected breakage set before promotion.
|
||||||
|
* **Cross‑cluster federation**: one Scheduler instance driving many Scanner clusters (tenant isolation).
|
||||||
|
* **Windows containers**: integrate Zastava runtime hints for Usage view tightening.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End — component_architecture_scheduler.md**
|
||||||
@@ -41,6 +41,8 @@ Everything here is open‑source and versioned — when you check out a git ta
|
|||||||
- [Signer](ARCHITECTURE_SIGNER.md)
|
- [Signer](ARCHITECTURE_SIGNER.md)
|
||||||
- [Attestor](ARCHITECTURE_ATTESTOR.md)
|
- [Attestor](ARCHITECTURE_ATTESTOR.md)
|
||||||
- [Authority](ARCHITECTURE_AUTHORITY.md)
|
- [Authority](ARCHITECTURE_AUTHORITY.md)
|
||||||
|
- [Notify](ARCHITECTURE_NOTIFY.md)
|
||||||
|
- [Scheduler](ARCHITECTURE_SCHEDULER.md)
|
||||||
- [CLI](ARCHITECTURE_CLI.md)
|
- [CLI](ARCHITECTURE_CLI.md)
|
||||||
- [Web UI](ARCHITECTURE_UI.md)
|
- [Web UI](ARCHITECTURE_UI.md)
|
||||||
- [Zastava Runtime](ARCHITECTURE_ZASTAVA.md)
|
- [Zastava Runtime](ARCHITECTURE_ZASTAVA.md)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
| DOC5.Concelier-Runbook | DONE (2025-10-12) | Docs Guild | DOC3.Concelier-Authority | Produce dedicated Concelier authority audit runbook covering log fields, monitoring recommendations, and troubleshooting steps. | ✅ Runbook published; ✅ linked from DOC3/DOC5; ✅ alerting guidance included. |
|
| DOC5.Concelier-Runbook | DONE (2025-10-12) | Docs Guild | DOC3.Concelier-Authority | Produce dedicated Concelier authority audit runbook covering log fields, monitoring recommendations, and troubleshooting steps. | ✅ Runbook published; ✅ linked from DOC3/DOC5; ✅ alerting guidance included. |
|
||||||
| FEEDDOCS-DOCS-05-001 | DONE (2025-10-11) | Docs Guild | FEEDMERGE-ENGINE-04-001, FEEDMERGE-ENGINE-04-002 | Publish Concelier conflict resolution runbook covering precedence workflow, merge-event auditing, and Sprint 3 metrics. | ✅ `docs/ops/concelier-conflict-resolution.md` committed; ✅ metrics/log tables align with latest merge code; ✅ Ops alert guidance handed to Concelier team. |
|
| FEEDDOCS-DOCS-05-001 | DONE (2025-10-11) | Docs Guild | FEEDMERGE-ENGINE-04-001, FEEDMERGE-ENGINE-04-002 | Publish Concelier conflict resolution runbook covering precedence workflow, merge-event auditing, and Sprint 3 metrics. | ✅ `docs/ops/concelier-conflict-resolution.md` committed; ✅ metrics/log tables align with latest merge code; ✅ Ops alert guidance handed to Concelier team. |
|
||||||
| FEEDDOCS-DOCS-05-002 | DONE (2025-10-16) | Docs Guild, Concelier Ops | FEEDDOCS-DOCS-05-001 | Ops sign-off captured: conflict runbook circulated, alert thresholds tuned, and rollout decisions documented in change log. | ✅ Ops review recorded; ✅ alert thresholds finalised using `docs/ops/concelier-authority-audit-runbook.md`; ✅ change-log entry linked from runbook once GHSA/NVD/OSV regression fixtures land. |
|
| FEEDDOCS-DOCS-05-002 | DONE (2025-10-16) | Docs Guild, Concelier Ops | FEEDDOCS-DOCS-05-001 | Ops sign-off captured: conflict runbook circulated, alert thresholds tuned, and rollout decisions documented in change log. | ✅ Ops review recorded; ✅ alert thresholds finalised using `docs/ops/concelier-authority-audit-runbook.md`; ✅ change-log entry linked from runbook once GHSA/NVD/OSV regression fixtures land. |
|
||||||
|
| DOCS-ADR-09-001 | TODO | Docs Guild, DevEx | — | Establish ADR process (`docs/adr/0000-template.md`) and document usage guidelines. | Template published; README snippet linking ADR process; announcement posted. |
|
||||||
|
| DOCS-EVENTS-09-002 | TODO | Docs Guild, Platform Events | SCANNER-EVENTS-15-201 | Publish event schema catalog (`docs/events/`) for `scanner.report.ready@1`, `scheduler.rescan.delta@1`, `attestor.logged@1`. | Schemas validated; docs/events/README summarises usage; Notify/Scheduler teams acknowledge. |
|
||||||
|
| DOCS-RUNTIME-17-004 | TODO | Docs Guild, Runtime Guild | SCANNER-EMIT-17-701, ZASTAVA-OBS-17-005, DEVOPS-REL-17-002 | Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval. | Architecture + operator docs updated with build-id sections, examples show `readelf` output + debuginfod usage, references linked from Offline Kit/Release guides. |
|
||||||
|
|
||||||
> Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`.
|
> Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`.
|
||||||
|
|
||||||
|
|||||||
18
docs/adr/0000-template.md
Normal file
18
docs/adr/0000-template.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# ADR-0000: Title
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- What decision needs to be made?
|
||||||
|
- What are the forces (requirements, constraints, stakeholders)?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
- Summary of the chosen option.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Positive/negative consequences.
|
||||||
|
- Follow-up actions or tasks.
|
||||||
|
|
||||||
|
## References
|
||||||
|
- Links to related ADRs, issues, documents.
|
||||||
9
docs/events/README.md
Normal file
9
docs/events/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Event Envelope Schemas
|
||||||
|
|
||||||
|
Versioned JSON Schemas for platform events consumed by Scheduler, Notify, and UI.
|
||||||
|
|
||||||
|
- `scanner.report.ready@1.json`
|
||||||
|
- `scheduler.rescan.delta@1.json`
|
||||||
|
- `attestor.logged@1.json`
|
||||||
|
|
||||||
|
Producers must bump the version suffix when introducing breaking changes; consumers validate incoming payloads against these schemas.
|
||||||
38
docs/events/attestor.logged@1.json
Normal file
38
docs/events/attestor.logged@1.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$id": "https://stella-ops.org/schemas/events/attestor.logged@1.json",
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["eventId", "kind", "tenant", "ts", "payload"],
|
||||||
|
"properties": {
|
||||||
|
"eventId": {"type": "string", "format": "uuid"},
|
||||||
|
"kind": {"const": "attestor.logged"},
|
||||||
|
"tenant": {"type": "string"},
|
||||||
|
"ts": {"type": "string", "format": "date-time"},
|
||||||
|
"payload": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["artifactSha256", "rekor", "subject"],
|
||||||
|
"properties": {
|
||||||
|
"artifactSha256": {"type": "string"},
|
||||||
|
"rekor": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["uuid", "url"],
|
||||||
|
"properties": {
|
||||||
|
"uuid": {"type": "string"},
|
||||||
|
"url": {"type": "string", "format": "uri"},
|
||||||
|
"index": {"type": "integer", "minimum": 0}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "name"],
|
||||||
|
"properties": {
|
||||||
|
"type": {"enum": ["sbom", "report", "vex-export"]},
|
||||||
|
"name": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
46
docs/events/scanner.report.ready@1.json
Normal file
46
docs/events/scanner.report.ready@1.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"$id": "https://stella-ops.org/schemas/events/scanner.report.ready@1.json",
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["eventId", "kind", "tenant", "ts", "scope", "payload"],
|
||||||
|
"properties": {
|
||||||
|
"eventId": {"type": "string", "format": "uuid"},
|
||||||
|
"kind": {"const": "scanner.report.ready"},
|
||||||
|
"tenant": {"type": "string"},
|
||||||
|
"ts": {"type": "string", "format": "date-time"},
|
||||||
|
"scope": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo", "digest"],
|
||||||
|
"properties": {
|
||||||
|
"namespace": {"type": "string"},
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"digest": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["verdict", "delta", "links"],
|
||||||
|
"properties": {
|
||||||
|
"verdict": {"enum": ["pass", "warn", "fail"]},
|
||||||
|
"delta": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"newCritical": {"type": "integer", "minimum": 0},
|
||||||
|
"newHigh": {"type": "integer", "minimum": 0},
|
||||||
|
"kev": {"type": "array", "items": {"type": "string"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ui": {"type": "string", "format": "uri"},
|
||||||
|
"rekor": {"type": "string", "format": "uri"}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
33
docs/events/scheduler.rescan.delta@1.json
Normal file
33
docs/events/scheduler.rescan.delta@1.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$id": "https://stella-ops.org/schemas/events/scheduler.rescan.delta@1.json",
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["eventId", "kind", "tenant", "ts", "payload"],
|
||||||
|
"properties": {
|
||||||
|
"eventId": {"type": "string", "format": "uuid"},
|
||||||
|
"kind": {"const": "scheduler.rescan.delta"},
|
||||||
|
"tenant": {"type": "string"},
|
||||||
|
"ts": {"type": "string", "format": "date-time"},
|
||||||
|
"payload": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["scheduleId", "impactedDigests", "summary"],
|
||||||
|
"properties": {
|
||||||
|
"scheduleId": {"type": "string"},
|
||||||
|
"impactedDigests": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"}
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"newCritical": {"type": "integer", "minimum": 0},
|
||||||
|
"newHigh": {"type": "integer", "minimum": 0},
|
||||||
|
"total": {"type": "integer", "minimum": 0}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
6
node_modules/.package-lock.json
generated
vendored
Normal file
6
node_modules/.package-lock.json
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "git.stella-ops.org",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
4
ops/deployment/AGENTS.md
Normal file
4
ops/deployment/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Deployment & Operations — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Maintain deployment/upgrade/rollback workflows (Helm/Compose) per `docs/ARCHITECTURE_DEVOPS.md` including environment-specific configs.
|
||||||
5
ops/deployment/TASKS.md
Normal file
5
ops/deployment/TASKS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Deployment Task Board
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| DEVOPS-OPS-14-003 | TODO | Deployment Guild | DEVOPS-REL-14-001 | Document and script upgrade/rollback flows, channel management, and compatibility matrices per architecture. | Helm/Compose guides updated with digest pinning, automated checks committed, rollback drill recorded. |
|
||||||
11
ops/devops/AGENTS.md
Normal file
11
ops/devops/AGENTS.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# DevOps & Release — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Execute deterministic build/release pipeline per `docs/ARCHITECTURE_DEVOPS.md`:
|
||||||
|
- Reproducible builds with SBOM/provenance, cosign signing, transparency logging.
|
||||||
|
- Channel manifests (LTS/Stable/Edge) with digests, Helm/Compose profiles.
|
||||||
|
- Performance guard jobs ensuring budgets.
|
||||||
|
|
||||||
|
## Expectations
|
||||||
|
- Coordinate with Scanner/Scheduler/Notify teams for artifact availability.
|
||||||
|
- Maintain CI reliability; update `TASKS.md` as states change.
|
||||||
9
ops/devops/TASKS.md
Normal file
9
ops/devops/TASKS.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# DevOps Task Board
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| DEVOPS-HELM-09-001 | TODO | DevOps Guild | SCANNER-WEB-09-101 | Create Helm/Compose environment profiles (dev, staging, airgap) with deterministic digests. | Profiles committed under `deploy/`; docs updated; CI smoke deploy passes. |
|
||||||
|
| DEVOPS-PERF-10-001 | TODO | DevOps Guild | BENCH-SCANNER-10-001 | Add perf smoke job (SBOM compose <5 s target) to CI. | CI job runs sample build verifying <5 s; alerts configured. |
|
||||||
|
| 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-MIRROR-08-001 | TODO | 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. |
|
||||||
4
ops/licensing/AGENTS.md
Normal file
4
ops/licensing/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Licensing & Registry Access — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implement licensing token service and registry access workflows described in `docs/ARCHITECTURE_DEVOPS.md`.
|
||||||
5
ops/licensing/TASKS.md
Normal file
5
ops/licensing/TASKS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Licensing Task Board
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| DEVOPS-LIC-14-004 | TODO | Licensing Guild | AUTH-MTLS-11-002 | Implement registry token service tied to Authority (DPoP/mTLS), plan gating, revocation handling, and monitoring per architecture. | Token service issues scoped tokens, revocation tested, monitoring dashboards in place, docs updated. |
|
||||||
4
ops/offline-kit/AGENTS.md
Normal file
4
ops/offline-kit/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Offline Kit — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Package Offline Update Kit per `docs/ARCHITECTURE_DEVOPS.md` and `docs/24_OFFLINE_KIT.md` with deterministic digests and import tooling.
|
||||||
5
ops/offline-kit/TASKS.md
Normal file
5
ops/offline-kit/TASKS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Offline Kit Task Board
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| DEVOPS-OFFLINE-14-002 | TODO | Offline Kit Guild | DEVOPS-REL-14-001 | Build offline kit packaging workflow (artifact bundling, manifest generation, signature verification). | Offline tarball generated with manifest + checksums + signatures; import script verifies integrity; docs updated. |
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "git.stella-ops.org",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
5
samples/TASKS.md
Normal file
5
samples/TASKS.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Samples Task Board
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| SAMPLES-10-001 | TODO | Samples Guild, Scanner Team | SCANNER-EMIT-10-605 | Curate sample images (nginx, alpine+busybox, distroless+go, .NET AOT, python venv, npm monorepo) with expected SBOM/BOM-Index sidecars. | Samples committed under `samples/`; golden SBOM/BOM-Index files present; documented usage. |
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -211,6 +212,113 @@ public sealed class CommandHandlersTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleExcititorInitAsync_CallsBackend()
|
||||||
|
{
|
||||||
|
var original = Environment.ExitCode;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var backend = new StubBackendClient(new JobTriggerResult(true, "accepted", null, null));
|
||||||
|
var provider = BuildServiceProvider(backend);
|
||||||
|
|
||||||
|
await CommandHandlers.HandleExcititorInitAsync(
|
||||||
|
provider,
|
||||||
|
new[] { "redhat" },
|
||||||
|
resume: true,
|
||||||
|
verbose: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, Environment.ExitCode);
|
||||||
|
Assert.Equal("init", backend.LastExcititorRoute);
|
||||||
|
Assert.Equal(HttpMethod.Post, backend.LastExcititorMethod);
|
||||||
|
var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload);
|
||||||
|
Assert.Equal(true, payload["resume"]);
|
||||||
|
var providers = Assert.IsAssignableFrom<IEnumerable<string>>(payload["providers"]!);
|
||||||
|
Assert.Contains("redhat", providers, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleExcititorListProvidersAsync_WritesOutput()
|
||||||
|
{
|
||||||
|
var original = Environment.ExitCode;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||||
|
{
|
||||||
|
ProviderSummaries = new[]
|
||||||
|
{
|
||||||
|
new ExcititorProviderSummary("redhat", "distro", "Red Hat", "vendor", true, DateTimeOffset.UtcNow)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var provider = BuildServiceProvider(backend);
|
||||||
|
await CommandHandlers.HandleExcititorListProvidersAsync(provider, includeDisabled: false, verbose: false, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, Environment.ExitCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleExcititorVerifyAsync_FailsWithoutArguments()
|
||||||
|
{
|
||||||
|
var original = Environment.ExitCode;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||||
|
var provider = BuildServiceProvider(backend);
|
||||||
|
|
||||||
|
await CommandHandlers.HandleExcititorVerifyAsync(provider, null, null, null, verbose: false, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, Environment.ExitCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleExcititorVerifyAsync_AttachesAttestationFile()
|
||||||
|
{
|
||||||
|
var original = Environment.ExitCode;
|
||||||
|
using var tempFile = new TempFile("attestation.json", Encoding.UTF8.GetBytes("{\"ok\":true}"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||||
|
var provider = BuildServiceProvider(backend);
|
||||||
|
|
||||||
|
await CommandHandlers.HandleExcititorVerifyAsync(
|
||||||
|
provider,
|
||||||
|
exportId: "export-123",
|
||||||
|
digest: "sha256:abc",
|
||||||
|
attestationPath: tempFile.Path,
|
||||||
|
verbose: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, Environment.ExitCode);
|
||||||
|
Assert.Equal("verify", backend.LastExcititorRoute);
|
||||||
|
var payload = Assert.IsAssignableFrom<IDictionary<string, object?>>(backend.LastExcititorPayload);
|
||||||
|
Assert.Equal("export-123", payload["exportId"]);
|
||||||
|
Assert.Equal("sha256:abc", payload["digest"]);
|
||||||
|
var attestation = Assert.IsAssignableFrom<IDictionary<string, object?>>(payload["attestation"]!);
|
||||||
|
Assert.Equal(Path.GetFileName(tempFile.Path), attestation["fileName"]);
|
||||||
|
Assert.NotNull(attestation["base64"]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null)]
|
[InlineData(null)]
|
||||||
[InlineData("default")]
|
[InlineData("default")]
|
||||||
@@ -504,15 +612,20 @@ public sealed class CommandHandlersTests
|
|||||||
|
|
||||||
private sealed class StubBackendClient : IBackendOperationsClient
|
private sealed class StubBackendClient : IBackendOperationsClient
|
||||||
{
|
{
|
||||||
private readonly JobTriggerResult _result;
|
private readonly JobTriggerResult _jobResult;
|
||||||
|
|
||||||
public StubBackendClient(JobTriggerResult result)
|
public StubBackendClient(JobTriggerResult result)
|
||||||
{
|
{
|
||||||
_result = result;
|
_jobResult = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? LastJobKind { get; private set; }
|
public string? LastJobKind { get; private set; }
|
||||||
public string? LastUploadPath { get; private set; }
|
public string? LastUploadPath { get; private set; }
|
||||||
|
public string? LastExcititorRoute { get; private set; }
|
||||||
|
public HttpMethod? LastExcititorMethod { get; private set; }
|
||||||
|
public object? LastExcititorPayload { get; private set; }
|
||||||
|
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
|
||||||
|
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
|
||||||
|
|
||||||
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
@@ -526,8 +639,19 @@ public sealed class CommandHandlersTests
|
|||||||
public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
public Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
LastJobKind = jobKind;
|
LastJobKind = jobKind;
|
||||||
return Task.FromResult(_result);
|
return Task.FromResult(_jobResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastExcititorRoute = route;
|
||||||
|
LastExcititorMethod = method;
|
||||||
|
LastExcititorPayload = payload;
|
||||||
|
return Task.FromResult(ExcititorResult ?? new ExcititorOperationResult(true, "ok", null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(ProviderSummaries);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubExecutor : IScannerExecutor
|
private sealed class StubExecutor : IScannerExecutor
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.IO;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace StellaOps.Cli.Tests.Testing;
|
namespace StellaOps.Cli.Tests.Testing;
|
||||||
|
|
||||||
@@ -33,6 +34,40 @@ internal sealed class TempDirectory : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class TempFile : IDisposable
|
||||||
|
{
|
||||||
|
public TempFile(string fileName, byte[] contents)
|
||||||
|
{
|
||||||
|
var directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-cli-file-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
Path = System.IO.Path.Combine(directory, fileName);
|
||||||
|
File.WriteAllBytes(Path, contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(Path))
|
||||||
|
{
|
||||||
|
File.Delete(Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
var directory = System.IO.Path.GetDirectoryName(Path);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored intentionally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
internal sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private readonly Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>> _responses;
|
private readonly Queue<Func<HttpRequestMessage, CancellationToken, HttpResponseMessage>> _responses;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal static class CommandFactory
|
|||||||
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
|
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
|
||||||
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
||||||
|
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
|
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
|
||||||
root.Add(BuildConfigCommand(options));
|
root.Add(BuildConfigCommand(options));
|
||||||
|
|
||||||
@@ -224,6 +225,187 @@ internal static class CommandFactory
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Command BuildExcititorCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var excititor = new Command("excititor", "Manage Excititor ingest, exports, and reconciliation workflows.");
|
||||||
|
|
||||||
|
var init = new Command("init", "Initialize Excititor ingest state.");
|
||||||
|
var initProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||||
|
{
|
||||||
|
Description = "Optional provider identifier(s) to initialize.",
|
||||||
|
Arity = ArgumentArity.ZeroOrMore
|
||||||
|
};
|
||||||
|
var resumeOption = new Option<bool>("--resume")
|
||||||
|
{
|
||||||
|
Description = "Resume ingest from the last persisted checkpoint instead of starting fresh."
|
||||||
|
};
|
||||||
|
init.Add(initProviders);
|
||||||
|
init.Add(resumeOption);
|
||||||
|
init.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var providers = parseResult.GetValue(initProviders) ?? Array.Empty<string>();
|
||||||
|
var resume = parseResult.GetValue(resumeOption);
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
return CommandHandlers.HandleExcititorInitAsync(services, providers, resume, verbose, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var pull = new Command("pull", "Trigger Excititor ingest for configured providers.");
|
||||||
|
var pullProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||||
|
{
|
||||||
|
Description = "Optional provider identifier(s) to ingest.",
|
||||||
|
Arity = ArgumentArity.ZeroOrMore
|
||||||
|
};
|
||||||
|
var sinceOption = new Option<DateTimeOffset?>("--since")
|
||||||
|
{
|
||||||
|
Description = "Optional ISO-8601 timestamp to begin the ingest window."
|
||||||
|
};
|
||||||
|
var windowOption = new Option<TimeSpan?>("--window")
|
||||||
|
{
|
||||||
|
Description = "Optional window duration (e.g. 24:00:00)."
|
||||||
|
};
|
||||||
|
var forceOption = new Option<bool>("--force")
|
||||||
|
{
|
||||||
|
Description = "Force ingestion even if the backend reports no pending work."
|
||||||
|
};
|
||||||
|
pull.Add(pullProviders);
|
||||||
|
pull.Add(sinceOption);
|
||||||
|
pull.Add(windowOption);
|
||||||
|
pull.Add(forceOption);
|
||||||
|
pull.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>();
|
||||||
|
var since = parseResult.GetValue(sinceOption);
|
||||||
|
var window = parseResult.GetValue(windowOption);
|
||||||
|
var force = parseResult.GetValue(forceOption);
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var resume = new Command("resume", "Resume Excititor ingest using a checkpoint token.");
|
||||||
|
var resumeProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||||
|
{
|
||||||
|
Description = "Optional provider identifier(s) to resume.",
|
||||||
|
Arity = ArgumentArity.ZeroOrMore
|
||||||
|
};
|
||||||
|
var checkpointOption = new Option<string?>("--checkpoint")
|
||||||
|
{
|
||||||
|
Description = "Optional checkpoint identifier to resume from."
|
||||||
|
};
|
||||||
|
resume.Add(resumeProviders);
|
||||||
|
resume.Add(checkpointOption);
|
||||||
|
resume.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var providers = parseResult.GetValue(resumeProviders) ?? Array.Empty<string>();
|
||||||
|
var checkpoint = parseResult.GetValue(checkpointOption);
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
return CommandHandlers.HandleExcititorResumeAsync(services, providers, checkpoint, verbose, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var list = new Command("list-providers", "List Excititor providers and their ingest status.");
|
||||||
|
var includeDisabledOption = new Option<bool>("--include-disabled")
|
||||||
|
{
|
||||||
|
Description = "Include disabled providers in the listing."
|
||||||
|
};
|
||||||
|
list.Add(includeDisabledOption);
|
||||||
|
list.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var includeDisabled = parseResult.GetValue(includeDisabledOption);
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var export = new Command("export", "Trigger Excititor export generation.");
|
||||||
|
var formatOption = new Option<string>("--format")
|
||||||
|
{
|
||||||
|
Description = "Export format (e.g. openvex, json)."
|
||||||
|
};
|
||||||
|
var exportDeltaOption = new Option<bool>("--delta")
|
||||||
|
{
|
||||||
|
Description = "Request a delta export when supported."
|
||||||
|
};
|
||||||
|
var exportScopeOption = new Option<string?>("--scope")
|
||||||
|
{
|
||||||
|
Description = "Optional policy scope or tenant identifier."
|
||||||
|
};
|
||||||
|
var exportSinceOption = new Option<DateTimeOffset?>("--since")
|
||||||
|
{
|
||||||
|
Description = "Optional ISO-8601 timestamp to restrict export contents."
|
||||||
|
};
|
||||||
|
var exportProviderOption = new Option<string?>("--provider")
|
||||||
|
{
|
||||||
|
Description = "Optional provider identifier when requesting targeted exports."
|
||||||
|
};
|
||||||
|
export.Add(formatOption);
|
||||||
|
export.Add(exportDeltaOption);
|
||||||
|
export.Add(exportScopeOption);
|
||||||
|
export.Add(exportSinceOption);
|
||||||
|
export.Add(exportProviderOption);
|
||||||
|
export.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var format = parseResult.GetValue(formatOption) ?? "openvex";
|
||||||
|
var delta = parseResult.GetValue(exportDeltaOption);
|
||||||
|
var scope = parseResult.GetValue(exportScopeOption);
|
||||||
|
var since = parseResult.GetValue(exportSinceOption);
|
||||||
|
var provider = parseResult.GetValue(exportProviderOption);
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
return CommandHandlers.HandleExcititorExportAsync(services, format, delta, scope, since, provider, verbose, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var verify = new Command("verify", "Verify Excititor exports or attestations.");
|
||||||
|
var exportIdOption = new Option<string?>("--export-id")
|
||||||
|
{
|
||||||
|
Description = "Export identifier to verify."
|
||||||
|
};
|
||||||
|
var digestOption = new Option<string?>("--digest")
|
||||||
|
{
|
||||||
|
Description = "Expected digest for the export or attestation."
|
||||||
|
};
|
||||||
|
var attestationOption = new Option<string?>("--attestation")
|
||||||
|
{
|
||||||
|
Description = "Path to a local attestation file to verify (base64 content will be uploaded)."
|
||||||
|
};
|
||||||
|
verify.Add(exportIdOption);
|
||||||
|
verify.Add(digestOption);
|
||||||
|
verify.Add(attestationOption);
|
||||||
|
verify.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var exportId = parseResult.GetValue(exportIdOption);
|
||||||
|
var digest = parseResult.GetValue(digestOption);
|
||||||
|
var attestation = parseResult.GetValue(attestationOption);
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
return CommandHandlers.HandleExcititorVerifyAsync(services, exportId, digest, attestation, verbose, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var reconcile = new Command("reconcile", "Trigger Excititor reconciliation against canonical advisories.");
|
||||||
|
var reconcileProviders = new Option<string[]>("--provider", new[] { "-p" })
|
||||||
|
{
|
||||||
|
Description = "Optional provider identifier(s) to reconcile.",
|
||||||
|
Arity = ArgumentArity.ZeroOrMore
|
||||||
|
};
|
||||||
|
var maxAgeOption = new Option<TimeSpan?>("--max-age")
|
||||||
|
{
|
||||||
|
Description = "Optional maximum age window (e.g. 7.00:00:00)."
|
||||||
|
};
|
||||||
|
reconcile.Add(reconcileProviders);
|
||||||
|
reconcile.Add(maxAgeOption);
|
||||||
|
reconcile.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>();
|
||||||
|
var maxAge = parseResult.GetValue(maxAgeOption);
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
excititor.Add(init);
|
||||||
|
excititor.Add(pull);
|
||||||
|
excititor.Add(resume);
|
||||||
|
excititor.Add(list);
|
||||||
|
excititor.Add(export);
|
||||||
|
excititor.Add(verify);
|
||||||
|
excititor.Add(reconcile);
|
||||||
|
return excititor;
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
|
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using System.Collections.Generic;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -340,6 +342,310 @@ internal static class CommandHandlers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Task HandleExcititorInitAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
IReadOnlyList<string> providers,
|
||||||
|
bool resume,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedProviders = NormalizeProviders(providers);
|
||||||
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
if (normalizedProviders.Count > 0)
|
||||||
|
{
|
||||||
|
payload["providers"] = normalizedProviders;
|
||||||
|
}
|
||||||
|
if (resume)
|
||||||
|
{
|
||||||
|
payload["resume"] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteExcititorCommandAsync(
|
||||||
|
services,
|
||||||
|
commandName: "excititor init",
|
||||||
|
verbose,
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["providers"] = normalizedProviders.Count,
|
||||||
|
["resume"] = resume
|
||||||
|
},
|
||||||
|
client => client.ExecuteExcititorOperationAsync("init", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task HandleExcititorPullAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
IReadOnlyList<string> providers,
|
||||||
|
DateTimeOffset? since,
|
||||||
|
TimeSpan? window,
|
||||||
|
bool force,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedProviders = NormalizeProviders(providers);
|
||||||
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
if (normalizedProviders.Count > 0)
|
||||||
|
{
|
||||||
|
payload["providers"] = normalizedProviders;
|
||||||
|
}
|
||||||
|
if (since.HasValue)
|
||||||
|
{
|
||||||
|
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
if (window.HasValue)
|
||||||
|
{
|
||||||
|
payload["window"] = window.Value.ToString("c", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
if (force)
|
||||||
|
{
|
||||||
|
payload["force"] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteExcititorCommandAsync(
|
||||||
|
services,
|
||||||
|
commandName: "excititor pull",
|
||||||
|
verbose,
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["providers"] = normalizedProviders.Count,
|
||||||
|
["force"] = force,
|
||||||
|
["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||||
|
["window"] = window?.ToString("c", CultureInfo.InvariantCulture)
|
||||||
|
},
|
||||||
|
client => client.ExecuteExcititorOperationAsync("ingest/run", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task HandleExcititorResumeAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
IReadOnlyList<string> providers,
|
||||||
|
string? checkpoint,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedProviders = NormalizeProviders(providers);
|
||||||
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
if (normalizedProviders.Count > 0)
|
||||||
|
{
|
||||||
|
payload["providers"] = normalizedProviders;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(checkpoint))
|
||||||
|
{
|
||||||
|
payload["checkpoint"] = checkpoint.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteExcititorCommandAsync(
|
||||||
|
services,
|
||||||
|
commandName: "excititor resume",
|
||||||
|
verbose,
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["providers"] = normalizedProviders.Count,
|
||||||
|
["checkpoint"] = checkpoint
|
||||||
|
},
|
||||||
|
client => client.ExecuteExcititorOperationAsync("ingest/resume", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task HandleExcititorListProvidersAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
bool includeDisabled,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-list-providers");
|
||||||
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||||
|
var previousLevel = verbosity.MinimumLevel;
|
||||||
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||||
|
using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.list-providers", ActivityKind.Client);
|
||||||
|
activity?.SetTag("stellaops.cli.command", "excititor list-providers");
|
||||||
|
activity?.SetTag("stellaops.cli.include_disabled", includeDisabled);
|
||||||
|
using var duration = CliMetrics.MeasureCommandDuration("excititor list-providers");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var providers = await client.GetExcititorProvidersAsync(includeDisabled, cancellationToken).ConfigureAwait(false);
|
||||||
|
Environment.ExitCode = 0;
|
||||||
|
logger.LogInformation("Providers returned: {Count}", providers.Count);
|
||||||
|
|
||||||
|
if (providers.Count > 0)
|
||||||
|
{
|
||||||
|
if (AnsiConsole.Profile.Capabilities.Interactive)
|
||||||
|
{
|
||||||
|
var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Trust", "Enabled", "Last Ingested");
|
||||||
|
foreach (var provider in providers)
|
||||||
|
{
|
||||||
|
table.AddRow(
|
||||||
|
provider.Id,
|
||||||
|
provider.Kind,
|
||||||
|
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
|
||||||
|
provider.Enabled ? "yes" : "no",
|
||||||
|
provider.LastIngestedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.Write(table);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var provider in providers)
|
||||||
|
{
|
||||||
|
logger.LogInformation("{ProviderId} [{Kind}] Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}",
|
||||||
|
provider.Id,
|
||||||
|
provider.Kind,
|
||||||
|
provider.Enabled ? "yes" : "no",
|
||||||
|
string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier,
|
||||||
|
provider.LastIngestedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to list Excititor providers.");
|
||||||
|
Environment.ExitCode = 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
verbosity.MinimumLevel = previousLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task HandleExcititorExportAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
string format,
|
||||||
|
bool delta,
|
||||||
|
string? scope,
|
||||||
|
DateTimeOffset? since,
|
||||||
|
string? provider,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(),
|
||||||
|
["delta"] = delta
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(scope))
|
||||||
|
{
|
||||||
|
payload["scope"] = scope.Trim();
|
||||||
|
}
|
||||||
|
if (since.HasValue)
|
||||||
|
{
|
||||||
|
payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(provider))
|
||||||
|
{
|
||||||
|
payload["provider"] = provider.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteExcititorCommandAsync(
|
||||||
|
services,
|
||||||
|
commandName: "excititor export",
|
||||||
|
verbose,
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["format"] = payload["format"],
|
||||||
|
["delta"] = delta,
|
||||||
|
["scope"] = scope,
|
||||||
|
["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||||
|
["provider"] = provider
|
||||||
|
},
|
||||||
|
client => client.ExecuteExcititorOperationAsync("export", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task HandleExcititorVerifyAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
string? exportId,
|
||||||
|
string? digest,
|
||||||
|
string? attestationPath,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(exportId) && string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(attestationPath))
|
||||||
|
{
|
||||||
|
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify");
|
||||||
|
logger.LogError("At least one of --export-id, --digest, or --attestation must be provided.");
|
||||||
|
Environment.ExitCode = 1;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
if (!string.IsNullOrWhiteSpace(exportId))
|
||||||
|
{
|
||||||
|
payload["exportId"] = exportId.Trim();
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(digest))
|
||||||
|
{
|
||||||
|
payload["digest"] = digest.Trim();
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(attestationPath))
|
||||||
|
{
|
||||||
|
var fullPath = Path.GetFullPath(attestationPath);
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-verify");
|
||||||
|
logger.LogError("Attestation file not found at {Path}.", fullPath);
|
||||||
|
Environment.ExitCode = 1;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = File.ReadAllBytes(fullPath);
|
||||||
|
payload["attestation"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["fileName"] = Path.GetFileName(fullPath),
|
||||||
|
["base64"] = Convert.ToBase64String(bytes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteExcititorCommandAsync(
|
||||||
|
services,
|
||||||
|
commandName: "excititor verify",
|
||||||
|
verbose,
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["export_id"] = exportId,
|
||||||
|
["digest"] = digest,
|
||||||
|
["attestation_path"] = attestationPath
|
||||||
|
},
|
||||||
|
client => client.ExecuteExcititorOperationAsync("verify", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task HandleExcititorReconcileAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
IReadOnlyList<string> providers,
|
||||||
|
TimeSpan? maxAge,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedProviders = NormalizeProviders(providers);
|
||||||
|
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
if (normalizedProviders.Count > 0)
|
||||||
|
{
|
||||||
|
payload["providers"] = normalizedProviders;
|
||||||
|
}
|
||||||
|
if (maxAge.HasValue)
|
||||||
|
{
|
||||||
|
payload["maxAge"] = maxAge.Value.ToString("c", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteExcititorCommandAsync(
|
||||||
|
services,
|
||||||
|
commandName: "excititor reconcile",
|
||||||
|
verbose,
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["providers"] = normalizedProviders.Count,
|
||||||
|
["max_age"] = maxAge?.ToString("c", CultureInfo.InvariantCulture)
|
||||||
|
},
|
||||||
|
client => client.ExecuteExcititorOperationAsync("reconcile", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task HandleAuthLoginAsync(
|
public static async Task HandleAuthLoginAsync(
|
||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
StellaOpsCliOptions options,
|
StellaOpsCliOptions options,
|
||||||
@@ -1111,6 +1417,103 @@ internal static class CommandHandlers
|
|||||||
"jti"
|
"jti"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static async Task ExecuteExcititorCommandAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
string commandName,
|
||||||
|
bool verbose,
|
||||||
|
IDictionary<string, object?>? activityTags,
|
||||||
|
Func<IBackendOperationsClient, Task<ExcititorOperationResult>> operation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger(commandName.Replace(' ', '-'));
|
||||||
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||||
|
var previousLevel = verbosity.MinimumLevel;
|
||||||
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||||
|
using var activity = CliActivitySource.Instance.StartActivity($"cli.{commandName.Replace(' ', '.')}" , ActivityKind.Client);
|
||||||
|
activity?.SetTag("stellaops.cli.command", commandName);
|
||||||
|
if (activityTags is not null)
|
||||||
|
{
|
||||||
|
foreach (var tag in activityTags)
|
||||||
|
{
|
||||||
|
activity?.SetTag(tag.Key, tag.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
using var duration = CliMetrics.MeasureCommandDuration(commandName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await operation(client).ConfigureAwait(false);
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.Message))
|
||||||
|
{
|
||||||
|
logger.LogInformation(result.Message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation("Operation completed successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.Location))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Location: {Location}", result.Location);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Payload is JsonElement payload && payload.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Response payload: {Payload}", payload.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Environment.ExitCode = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Operation failed." : result.Message);
|
||||||
|
Environment.ExitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Excititor operation failed.");
|
||||||
|
Environment.ExitCode = 1;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
verbosity.MinimumLevel = previousLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
|
||||||
|
{
|
||||||
|
if (providers is null || providers.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var provider in providers)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(provider))
|
||||||
|
{
|
||||||
|
list.Add(provider.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.Count == 0 ? Array.Empty<string>() : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
|
||||||
|
{
|
||||||
|
foreach (var key in source.Where(kvp => kvp.Value is null).Select(kvp => kvp.Key).ToList())
|
||||||
|
{
|
||||||
|
source.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task TriggerJobAsync(
|
private static async Task TriggerJobAsync(
|
||||||
IBackendOperationsClient client,
|
IBackendOperationsClient client,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
|
|||||||
@@ -235,6 +235,96 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
|||||||
return new JobTriggerResult(false, failureMessage, null, null);
|
return new JobTriggerResult(false, failureMessage, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureBackendConfigured();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(route))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Route must be provided.", nameof(route));
|
||||||
|
}
|
||||||
|
|
||||||
|
var relative = route.TrimStart('/');
|
||||||
|
using var request = CreateRequest(method, $"excititor/{relative}");
|
||||||
|
|
||||||
|
if (payload is not null && method != HttpMethod.Get && method != HttpMethod.Delete)
|
||||||
|
{
|
||||||
|
request.Content = JsonContent.Create(payload, options: SerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var (message, payloadElement) = await ExtractExcititorResponseAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
var location = response.Headers.Location?.ToString();
|
||||||
|
return new ExcititorOperationResult(true, message, location, payloadElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
return new ExcititorOperationResult(false, failure, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureBackendConfigured();
|
||||||
|
|
||||||
|
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
|
||||||
|
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
|
||||||
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
throw new InvalidOperationException(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Content is null || response.Content.Headers.ContentLength is 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<ExcititorProviderSummary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (stream is null || stream.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<ExcititorProviderSummary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("providers", out var providersProperty))
|
||||||
|
{
|
||||||
|
root = providersProperty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return Array.Empty<ExcititorProviderSummary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = new List<ExcititorProviderSummary>();
|
||||||
|
foreach (var item in root.EnumerateArray())
|
||||||
|
{
|
||||||
|
var id = GetStringProperty(item, "id") ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind = GetStringProperty(item, "kind") ?? "unknown";
|
||||||
|
var displayName = GetStringProperty(item, "displayName") ?? id;
|
||||||
|
var trustTier = GetStringProperty(item, "trustTier") ?? string.Empty;
|
||||||
|
var enabled = GetBooleanProperty(item, "enabled", defaultValue: true);
|
||||||
|
var lastIngested = GetDateTimeOffsetProperty(item, "lastIngestedAt");
|
||||||
|
|
||||||
|
list.Add(new ExcititorProviderSummary(id, kind, displayName, trustTier, enabled, lastIngested));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||||
@@ -328,6 +418,110 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(string Message, JsonElement? Payload)> ExtractExcititorResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.Content is null || response.Content.Headers.ContentLength is 0)
|
||||||
|
{
|
||||||
|
return ($"HTTP {(int)response.StatusCode}", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (stream is null || stream.Length == 0)
|
||||||
|
{
|
||||||
|
return ($"HTTP {(int)response.StatusCode}", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
var root = document.RootElement.Clone();
|
||||||
|
string? message = null;
|
||||||
|
if (root.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
message = GetStringProperty(root, "message") ?? GetStringProperty(root, "status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
message = root.ValueKind == JsonValueKind.Object || root.ValueKind == JsonValueKind.Array
|
||||||
|
? root.ToString()
|
||||||
|
: root.GetRawText();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (message ?? $"HTTP {(int)response.StatusCode}", root);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return (string.IsNullOrWhiteSpace(text) ? $"HTTP {(int)response.StatusCode}" : text.Trim(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var candidate in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
property = candidate.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
property = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetStringProperty(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
|
||||||
|
{
|
||||||
|
if (property.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return property.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool GetBooleanProperty(JsonElement element, string propertyName, bool defaultValue)
|
||||||
|
{
|
||||||
|
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
|
||||||
|
{
|
||||||
|
return property.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed,
|
||||||
|
_ => defaultValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
if (DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
|
||||||
|
{
|
||||||
|
return parsed.ToUniversalTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureBackendConfigured()
|
private void EnsureBackendConfigured()
|
||||||
{
|
{
|
||||||
if (_httpClient.BaseAddress is null)
|
if (_httpClient.BaseAddress is null)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using StellaOps.Cli.Configuration;
|
using StellaOps.Cli.Configuration;
|
||||||
@@ -13,4 +14,8 @@ internal interface IBackendOperationsClient
|
|||||||
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
|
Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
|
Task<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> parameters, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<ExcititorOperationResult> ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace StellaOps.Cli.Services.Models;
|
||||||
|
|
||||||
|
internal sealed record ExcititorOperationResult(
|
||||||
|
bool Success,
|
||||||
|
string Message,
|
||||||
|
string? Location,
|
||||||
|
JsonElement? Payload);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StellaOps.Cli.Services.Models;
|
||||||
|
|
||||||
|
internal sealed record ExcititorProviderSummary(
|
||||||
|
string Id,
|
||||||
|
string Kind,
|
||||||
|
string DisplayName,
|
||||||
|
string TrustTier,
|
||||||
|
bool Enabled,
|
||||||
|
DateTimeOffset? LastIngestedAt);
|
||||||
@@ -14,6 +14,9 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|
|||||||
|Expose auth client resilience settings|DevEx/CLI|Auth libraries LIB5|**DONE (2025-10-10)** – CLI options now bind resilience knobs, `AddStellaOpsAuthClient` honours them, and tests cover env overrides.|
|
|Expose auth client resilience settings|DevEx/CLI|Auth libraries LIB5|**DONE (2025-10-10)** – CLI options now bind resilience knobs, `AddStellaOpsAuthClient` honours them, and tests cover env overrides.|
|
||||||
|Document advanced Authority tuning|Docs/CLI|Expose auth client resilience settings|**DONE (2025-10-10)** – docs/09 and docs/10 describe retry/offline settings with env examples and point to the integration guide.|
|
|Document advanced Authority tuning|Docs/CLI|Expose auth client resilience settings|**DONE (2025-10-10)** – docs/09 and docs/10 describe retry/offline settings with env examples and point to the integration guide.|
|
||||||
|Surface password policy diagnostics in CLI output|DevEx/CLI, Security Guild|AUTHSEC-CRYPTO-02-004|**DONE (2025-10-15)** – CLI startup runs the Authority plug-in analyzer, logs weakened password policy warnings with manifest paths, added unit tests (`dotnet test src/StellaOps.Cli.Tests`) and updated docs/09 with remediation guidance.|
|
|Surface password policy diagnostics in CLI output|DevEx/CLI, Security Guild|AUTHSEC-CRYPTO-02-004|**DONE (2025-10-15)** – CLI startup runs the Authority plug-in analyzer, logs weakened password policy warnings with manifest paths, added unit tests (`dotnet test src/StellaOps.Cli.Tests`) and updated docs/09 with remediation guidance.|
|
||||||
|EXCITITOR-CLI-01-001 – Add `excititor` command group|DevEx/CLI|EXCITITOR-WEB-01-001|TODO – Introduce `excititor` verb hierarchy (init/pull/resume/list-providers/export/verify/reconcile) forwarding to WebService with token auth and consistent exit codes.|
|
|EXCITITOR-CLI-01-001 – Add `excititor` command group|DevEx/CLI|EXCITITOR-WEB-01-001|DONE (2025-10-18) – Introduced `excititor` verbs (init/pull/resume/list-providers/export/verify/reconcile) with token-auth backend calls, provenance-friendly logging, and regression coverage.|
|
||||||
|EXCITITOR-CLI-01-002 – Export download & attestation UX|DevEx/CLI|EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001|TODO – Display export metadata (sha256, size, Rekor link), support optional artifact download path, and handle cache hits gracefully.|
|
|EXCITITOR-CLI-01-002 – Export download & attestation UX|DevEx/CLI|EXCITITOR-CLI-01-001, EXCITITOR-EXPORT-01-001|TODO – Display export metadata (sha256, size, Rekor link), support optional artifact download path, and handle cache hits gracefully.|
|
||||||
|EXCITITOR-CLI-01-003 – CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|TODO – Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.|
|
|EXCITITOR-CLI-01-003 – CLI docs & examples for Excititor|Docs/CLI|EXCITITOR-CLI-01-001|TODO – Update docs/09_API_CLI_REFERENCE.md and quickstart snippets to cover Excititor verbs, offline guidance, and attestation verification workflow.|
|
||||||
|
|CLI-RUNTIME-13-005 – Runtime policy test verbs|DevEx/CLI|SCANNER-RUNTIME-12-302, ZASTAVA-WEBHOOK-12-102|TODO – Add `runtime policy test` and related verbs to query `/policy/runtime`, display verdicts/TTL/reasons, and support batch inputs.|
|
||||||
|
|CLI-OFFLINE-13-006 – Offline kit workflows|DevEx/CLI|DEVOPS-OFFLINE-14-002|TODO – Implement `offline kit pull/import/status` commands with integrity checks, resumable downloads, and doc updates.|
|
||||||
|
|CLI-PLUGIN-13-007 – Plugin packaging|DevEx/CLI|CLI-RUNTIME-13-005, CLI-OFFLINE-13-006|TODO – Package non-core verbs as restart-time plug-ins (manifest + loader updates, tests ensuring no hot reload).|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# StellaOps Mirror Connector Task Board (Sprint 8)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| FEEDCONN-STELLA-08-001 | TODO | BE-Conn-Stella | CONCELIER-EXPORT-08-201 | Implement Concelier mirror fetcher hitting `https://<domain>.stella-ops.org/concelier/exports/index.json`, verify signatures/digests, and persist raw documents with provenance. | Fetch job downloads mirror manifest, verifies digest/signature, stores raw docs with tests covering happy-path + tampered manifest. |
|
||||||
|
| FEEDCONN-STELLA-08-002 | TODO | BE-Conn-Stella | FEEDCONN-STELLA-08-001 | Map mirror payloads into canonical advisory DTOs with provenance referencing mirror domain + original source metadata. | Mapper produces advisories/aliases/affected with mirror provenance; fixtures assert canonical parity with upstream JSON exporters. |
|
||||||
|
| FEEDCONN-STELLA-08-003 | TODO | BE-Conn-Stella | FEEDCONN-STELLA-08-002 | Add incremental cursor + resume support (per-export fingerprint) and document configuration for downstream Concelier instances. | Connector resumes from last export, handles deletion/delta cases, docs updated with config sample; integration test covers resume + new export scenario. |
|
||||||
@@ -18,3 +18,4 @@
|
|||||||
|Reference normalization & freshness instrumentation cleanup|BE-Core, QA|Models|DONE (2025-10-15) – reference keys normalized, freshness overrides applied to union fields, and new tests assert decision logging.|
|
|Reference normalization & freshness instrumentation cleanup|BE-Core, QA|Models|DONE (2025-10-15) – reference keys normalized, freshness overrides applied to union fields, and new tests assert decision logging.|
|
||||||
|FEEDCORE-ENGINE-07-001 – Advisory event log & asOf queries|Team Core Engine & Storage Analytics|FEEDSTORAGE-DATA-07-001|TODO – Introduce immutable advisory statement events, expose `asOf` query surface for merge/export pipelines, and document determinism guarantees for replay.|
|
|FEEDCORE-ENGINE-07-001 – Advisory event log & asOf queries|Team Core Engine & Storage Analytics|FEEDSTORAGE-DATA-07-001|TODO – Introduce immutable advisory statement events, expose `asOf` query surface for merge/export pipelines, and document determinism guarantees for replay.|
|
||||||
|FEEDCORE-ENGINE-07-002 – Noise prior computation service|Team Core Engine & Data Science|FEEDCORE-ENGINE-07-001|TODO – Build rule-based learner capturing false-positive priors per package/env, persist summaries, and expose APIs for Excititor/scan suppressors with reproducible statistics.|
|
|FEEDCORE-ENGINE-07-002 – Noise prior computation service|Team Core Engine & Data Science|FEEDCORE-ENGINE-07-001|TODO – Build rule-based learner capturing false-positive priors per package/env, persist summaries, and expose APIs for Excititor/scan suppressors with reproducible statistics.|
|
||||||
|
|FEEDCORE-ENGINE-07-003 – Unknown state ledger & confidence seeding|Team Core Engine & Storage Analytics|FEEDCORE-ENGINE-07-001|TODO – Persist `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with initial confidence bands, expose query surface for Policy, and add fixtures validating canonical serialization.|
|
||||||
|
|||||||
@@ -10,3 +10,4 @@
|
|||||||
|Stream advisories during export|BE-Export|Storage.Mongo|DONE – exporter + streaming-only test ensures single enumeration and per-file digest capture.|
|
|Stream advisories during export|BE-Export|Storage.Mongo|DONE – exporter + streaming-only test ensures single enumeration and per-file digest capture.|
|
||||||
|Emit export manifest with digest metadata|BE-Export|Exporters|DONE – manifest now includes per-file digests/sizes alongside tree digest.|
|
|Emit export manifest with digest metadata|BE-Export|Exporters|DONE – manifest now includes per-file digests/sizes alongside tree digest.|
|
||||||
|Surface new advisory fields (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – JSON exporter validated with new fixtures ensuring description/CWEs/canonical metric are preserved in outputs; `dotnet test src/StellaOps.Concelier.Exporter.Json.Tests` run 2025-10-15 for regression coverage.|
|
|Surface new advisory fields (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – JSON exporter validated with new fixtures ensuring description/CWEs/canonical metric are preserved in outputs; `dotnet test src/StellaOps.Concelier.Exporter.Json.Tests` run 2025-10-15 for regression coverage.|
|
||||||
|
|CONCELIER-EXPORT-08-201 – Mirror bundle + domain manifest|Team Concelier Export|FEEDCORE-ENGINE-07-001|TODO – Produce per-domain aggregate bundles (JSON + manifest) with deterministic digests, include upstream source metadata, and publish index consumed by mirror endpoints/tests.|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|Streamed package building to avoid large copies|BE-Export|Exporters|DONE – metadata/config now reuse backing arrays and OCI writer streams directly without double buffering.|
|
|Streamed package building to avoid large copies|BE-Export|Exporters|DONE – metadata/config now reuse backing arrays and OCI writer streams directly without double buffering.|
|
||||||
|Plan incremental/delta exports|BE-Export|Exporters|DONE – state captures per-file manifests, planner schedules delta vs full resets, layer reuse smoke test verifies OCI reuse, and operator guide documents the validation flow.|
|
|Plan incremental/delta exports|BE-Export|Exporters|DONE – state captures per-file manifests, planner schedules delta vs full resets, layer reuse smoke test verifies OCI reuse, and operator guide documents the validation flow.|
|
||||||
|Advisory schema parity export (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – exporter/test fixtures updated to handle description/CWEs/canonical metric fields during Trivy DB packaging; `dotnet test src/StellaOps.Concelier.Exporter.TrivyDb.Tests` re-run 2025-10-15 to confirm coverage.|
|
|Advisory schema parity export (description/CWEs/canonical metric)|BE-Export|Models, Core|DONE (2025-10-15) – exporter/test fixtures updated to handle description/CWEs/canonical metric fields during Trivy DB packaging; `dotnet test src/StellaOps.Concelier.Exporter.TrivyDb.Tests` re-run 2025-10-15 to confirm coverage.|
|
||||||
|
|CONCELIER-EXPORT-08-202 – Mirror-ready Trivy DB bundles|Team Concelier Export|CONCELIER-EXPORT-08-201|TODO – Generate domain-specific Trivy DB archives + metadata manifest, ensure deterministic digests, and document sync process for downstream Concelier nodes.|
|
||||||
|
|||||||
@@ -22,3 +22,4 @@
|
|||||||
|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|**TODO** – Point Concelier source/exporter build outputs to `StellaOps.Concelier.PluginBinaries`, update PluginHost defaults/search patterns to match, ensure Offline Kit packaging/tests expect the new folder, and document migration guidance for operators.|
|
|Rename plugin drop directory to namespaced path|BE-Base|Plugins|**TODO** – Point Concelier source/exporter build outputs to `StellaOps.Concelier.PluginBinaries`, update PluginHost defaults/search patterns to match, ensure Offline Kit packaging/tests expect the new folder, and document migration guidance for operators.|
|
||||||
|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|TODO – Add domain-scoped mirror configuration (`*.stella-ops.org`), expose signed export index/download APIs with quota and auth, and document sync workflow for downstream Concelier instances.|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
using StellaOps.Excititor.Core;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO.Abstractions.TestingHelpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Configuration;
|
||||||
|
|
||||||
|
public sealed class OciOpenVexAttestationConnectorOptionsValidatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithValidConfiguration_Succeeds()
|
||||||
|
{
|
||||||
|
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||||
|
{
|
||||||
|
["/offline/registry.example.com/repo/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||||
|
});
|
||||||
|
|
||||||
|
var validator = new OciOpenVexAttestationConnectorOptionsValidator(fileSystem);
|
||||||
|
var options = new OciOpenVexAttestationConnectorOptions
|
||||||
|
{
|
||||||
|
AllowHttpRegistries = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Images.Add(new OciImageSubscriptionOptions
|
||||||
|
{
|
||||||
|
Reference = "registry.example.com/repo/image:latest",
|
||||||
|
OfflineBundlePath = "/offline/registry.example.com/repo/latest/openvex-attestations.tgz",
|
||||||
|
});
|
||||||
|
|
||||||
|
options.Registry.Username = "user";
|
||||||
|
options.Registry.Password = "pass";
|
||||||
|
|
||||||
|
options.Cosign.Mode = CosignCredentialMode.None;
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||||
|
|
||||||
|
errors.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenImagesMissing_AddsError()
|
||||||
|
{
|
||||||
|
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
|
||||||
|
var options = new OciOpenVexAttestationConnectorOptions();
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||||
|
|
||||||
|
errors.Should().ContainSingle().Which.Should().Contain("At least one OCI image reference must be configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WhenDigestMalformed_AddsError()
|
||||||
|
{
|
||||||
|
var validator = new OciOpenVexAttestationConnectorOptionsValidator(new MockFileSystem());
|
||||||
|
var options = new OciOpenVexAttestationConnectorOptions();
|
||||||
|
options.Images.Add(new OciImageSubscriptionOptions
|
||||||
|
{
|
||||||
|
Reference = "registry.test/repo/image@sha256:not-a-digest",
|
||||||
|
});
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
validator.Validate(new VexConnectorDescriptor("id", VexProviderKind.Attestation, "display"), options, errors);
|
||||||
|
|
||||||
|
errors.Should().ContainSingle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||||
|
using StellaOps.Excititor.Core;
|
||||||
|
using System.IO.Abstractions.TestingHelpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector;
|
||||||
|
|
||||||
|
public sealed class OciOpenVexAttestationConnectorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument()
|
||||||
|
{
|
||||||
|
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||||
|
{
|
||||||
|
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var httpClient = new HttpClient(new StubHttpMessageHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new System.Uri("https://registry.example.com/")
|
||||||
|
};
|
||||||
|
|
||||||
|
var httpFactory = new SingleClientHttpClientFactory(httpClient);
|
||||||
|
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||||
|
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
|
||||||
|
|
||||||
|
var connector = new OciOpenVexAttestationConnector(
|
||||||
|
discovery,
|
||||||
|
fetcher,
|
||||||
|
NullLogger<OciOpenVexAttestationConnector>.Instance,
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
var settingsValues = ImmutableDictionary<string, string>.Empty
|
||||||
|
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
|
||||||
|
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
|
||||||
|
.Add("Offline:PreferOffline", "true")
|
||||||
|
.Add("Offline:AllowNetworkFallback", "false")
|
||||||
|
.Add("Cosign:Mode", "None");
|
||||||
|
|
||||||
|
var settings = new VexConnectorSettings(settingsValues);
|
||||||
|
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||||
|
|
||||||
|
var sink = new CapturingRawSink();
|
||||||
|
var verifier = new CapturingSignatureVerifier();
|
||||||
|
var context = new VexConnectorContext(
|
||||||
|
Since: null,
|
||||||
|
Settings: VexConnectorSettings.Empty,
|
||||||
|
RawSink: sink,
|
||||||
|
SignatureVerifier: verifier,
|
||||||
|
Normalizers: new NoopNormalizerRouter(),
|
||||||
|
Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider());
|
||||||
|
|
||||||
|
var documents = new List<VexRawDocument>();
|
||||||
|
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||||
|
{
|
||||||
|
documents.Add(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
documents.Should().HaveCount(1);
|
||||||
|
sink.Documents.Should().HaveCount(1);
|
||||||
|
documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation);
|
||||||
|
documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline");
|
||||||
|
documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline");
|
||||||
|
documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous");
|
||||||
|
verifier.VerifyCalls.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance()
|
||||||
|
{
|
||||||
|
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||||
|
{
|
||||||
|
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var httpClient = new HttpClient(new StubHttpMessageHandler())
|
||||||
|
{
|
||||||
|
BaseAddress = new System.Uri("https://registry.example.com/")
|
||||||
|
};
|
||||||
|
|
||||||
|
var httpFactory = new SingleClientHttpClientFactory(httpClient);
|
||||||
|
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||||
|
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
|
||||||
|
|
||||||
|
var connector = new OciOpenVexAttestationConnector(
|
||||||
|
discovery,
|
||||||
|
fetcher,
|
||||||
|
NullLogger<OciOpenVexAttestationConnector>.Instance,
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
var settingsValues = ImmutableDictionary<string, string>.Empty
|
||||||
|
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
|
||||||
|
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
|
||||||
|
.Add("Offline:PreferOffline", "true")
|
||||||
|
.Add("Offline:AllowNetworkFallback", "false")
|
||||||
|
.Add("Cosign:Mode", "Keyless")
|
||||||
|
.Add("Cosign:Keyless:Issuer", "https://issuer.example.com")
|
||||||
|
.Add("Cosign:Keyless:Subject", "subject@example.com");
|
||||||
|
|
||||||
|
var settings = new VexConnectorSettings(settingsValues);
|
||||||
|
await connector.ValidateAsync(settings, CancellationToken.None);
|
||||||
|
|
||||||
|
var sink = new CapturingRawSink();
|
||||||
|
var verifier = new CapturingSignatureVerifier
|
||||||
|
{
|
||||||
|
Result = new VexSignatureMetadata(
|
||||||
|
type: "cosign",
|
||||||
|
subject: "sig-subject",
|
||||||
|
issuer: "sig-issuer",
|
||||||
|
keyId: "key-id",
|
||||||
|
verifiedAt: DateTimeOffset.UtcNow,
|
||||||
|
transparencyLogReference: "rekor://entry/123")
|
||||||
|
};
|
||||||
|
|
||||||
|
var context = new VexConnectorContext(
|
||||||
|
Since: null,
|
||||||
|
Settings: VexConnectorSettings.Empty,
|
||||||
|
RawSink: sink,
|
||||||
|
SignatureVerifier: verifier,
|
||||||
|
Normalizers: new NoopNormalizerRouter(),
|
||||||
|
Services: new ServiceCollection().BuildServiceProvider());
|
||||||
|
|
||||||
|
var documents = new List<VexRawDocument>();
|
||||||
|
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
|
||||||
|
{
|
||||||
|
documents.Add(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
documents.Should().HaveCount(1);
|
||||||
|
var metadata = documents[0].Metadata;
|
||||||
|
metadata.Should().Contain("vex.signature.type", "cosign");
|
||||||
|
metadata.Should().Contain("vex.signature.subject", "sig-subject");
|
||||||
|
metadata.Should().Contain("vex.signature.issuer", "sig-issuer");
|
||||||
|
metadata.Should().Contain("vex.signature.keyId", "key-id");
|
||||||
|
metadata.Should().ContainKey("vex.signature.verifiedAt");
|
||||||
|
metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123");
|
||||||
|
metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless");
|
||||||
|
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
|
||||||
|
metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com");
|
||||||
|
verifier.VerifyCalls.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingRawSink : IVexRawDocumentSink
|
||||||
|
{
|
||||||
|
public List<VexRawDocument> Documents { get; } = new();
|
||||||
|
|
||||||
|
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Documents.Add(document);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingSignatureVerifier : IVexSignatureVerifier
|
||||||
|
{
|
||||||
|
public int VerifyCalls { get; private set; }
|
||||||
|
|
||||||
|
public VexSignatureMetadata? Result { get; set; }
|
||||||
|
|
||||||
|
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
VerifyCalls++;
|
||||||
|
return ValueTask.FromResult(Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
|
||||||
|
{
|
||||||
|
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||||
|
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public SingleClientHttpClientFactory(HttpClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient CreateClient(string name) => _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
RequestMessage = request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO.Abstractions.TestingHelpers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Discovery;
|
||||||
|
|
||||||
|
public sealed class OciAttestationDiscoveryServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_ResolvesOfflinePaths()
|
||||||
|
{
|
||||||
|
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||||
|
{
|
||||||
|
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||||
|
|
||||||
|
var options = new OciOpenVexAttestationConnectorOptions
|
||||||
|
{
|
||||||
|
AllowHttpRegistries = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Images.Add(new OciImageSubscriptionOptions
|
||||||
|
{
|
||||||
|
Reference = "registry.example.com/repo/image:latest",
|
||||||
|
});
|
||||||
|
|
||||||
|
options.Offline.RootDirectory = "/bundles";
|
||||||
|
options.Cosign.Mode = CosignCredentialMode.None;
|
||||||
|
|
||||||
|
var result = await service.LoadAsync(options, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Targets.Should().ContainSingle();
|
||||||
|
result.Targets[0].OfflineBundle.Should().NotBeNull();
|
||||||
|
var offline = result.Targets[0].OfflineBundle!;
|
||||||
|
offline.Exists.Should().BeTrue();
|
||||||
|
var expectedPath = fileSystem.Path.Combine(
|
||||||
|
fileSystem.Path.GetFullPath("/bundles"),
|
||||||
|
"registry.example.com",
|
||||||
|
"repo",
|
||||||
|
"image",
|
||||||
|
"latest",
|
||||||
|
"openvex-attestations.tgz");
|
||||||
|
offline.Path.Should().Be(expectedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoadAsync_CachesResults()
|
||||||
|
{
|
||||||
|
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
|
||||||
|
{
|
||||||
|
["/bundles/registry.example.com/repo/image/latest/openvex-attestations.tgz"] = new MockFileData(string.Empty),
|
||||||
|
});
|
||||||
|
|
||||||
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||||
|
var service = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
|
||||||
|
|
||||||
|
var options = new OciOpenVexAttestationConnectorOptions
|
||||||
|
{
|
||||||
|
AllowHttpRegistries = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Images.Add(new OciImageSubscriptionOptions
|
||||||
|
{
|
||||||
|
Reference = "registry.example.com/repo/image:latest",
|
||||||
|
});
|
||||||
|
|
||||||
|
options.Offline.RootDirectory = "/bundles";
|
||||||
|
options.Cosign.Mode = CosignCredentialMode.None;
|
||||||
|
|
||||||
|
var first = await service.LoadAsync(options, CancellationToken.None);
|
||||||
|
var second = await service.LoadAsync(options, CancellationToken.None);
|
||||||
|
|
||||||
|
ReferenceEquals(first, second).Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<WarningsNotAsErrors>NU1903</WarningsNotAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest\StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||||
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
|
||||||
|
|
||||||
|
public sealed record CosignKeylessIdentity(
|
||||||
|
string Issuer,
|
||||||
|
string Subject,
|
||||||
|
Uri? FulcioUrl,
|
||||||
|
Uri? RekorUrl,
|
||||||
|
string? ClientId,
|
||||||
|
string? ClientSecret,
|
||||||
|
string? Audience,
|
||||||
|
string? IdentityToken);
|
||||||
|
|
||||||
|
public sealed record CosignKeyPairIdentity(
|
||||||
|
string PrivateKeyPath,
|
||||||
|
string? Password,
|
||||||
|
string? CertificatePath,
|
||||||
|
Uri? RekorUrl,
|
||||||
|
string? FulcioRootPath);
|
||||||
|
|
||||||
|
public sealed record OciCosignAuthority(
|
||||||
|
CosignCredentialMode Mode,
|
||||||
|
CosignKeylessIdentity? Keyless,
|
||||||
|
CosignKeyPairIdentity? KeyPair,
|
||||||
|
bool RequireSignature,
|
||||||
|
TimeSpan VerifyTimeout);
|
||||||
|
|
||||||
|
public static class OciCosignAuthorityFactory
|
||||||
|
{
|
||||||
|
public static OciCosignAuthority Create(OciCosignVerificationOptions options, IFileSystem? fileSystem = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
CosignKeylessIdentity? keyless = null;
|
||||||
|
CosignKeyPairIdentity? keyPair = null;
|
||||||
|
|
||||||
|
switch (options.Mode)
|
||||||
|
{
|
||||||
|
case CosignCredentialMode.None:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CosignCredentialMode.Keyless:
|
||||||
|
keyless = CreateKeyless(options.Keyless);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CosignCredentialMode.KeyPair:
|
||||||
|
keyPair = CreateKeyPair(options.KeyPair, fileSystem);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unsupported Cosign credential mode '{options.Mode}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OciCosignAuthority(
|
||||||
|
Mode: options.Mode,
|
||||||
|
Keyless: keyless,
|
||||||
|
KeyPair: keyPair,
|
||||||
|
RequireSignature: options.RequireSignature,
|
||||||
|
VerifyTimeout: options.VerifyTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CosignKeylessIdentity CreateKeyless(CosignKeylessOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
Uri? fulcio = null;
|
||||||
|
Uri? rekor = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.FulcioUrl))
|
||||||
|
{
|
||||||
|
fulcio = new Uri(options.FulcioUrl, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.RekorUrl))
|
||||||
|
{
|
||||||
|
rekor = new Uri(options.RekorUrl, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CosignKeylessIdentity(
|
||||||
|
Issuer: options.Issuer!,
|
||||||
|
Subject: options.Subject!,
|
||||||
|
FulcioUrl: fulcio,
|
||||||
|
RekorUrl: rekor,
|
||||||
|
ClientId: options.ClientId,
|
||||||
|
ClientSecret: options.ClientSecret,
|
||||||
|
Audience: options.Audience,
|
||||||
|
IdentityToken: options.IdentityToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CosignKeyPairIdentity CreateKeyPair(CosignKeyPairOptions options, IFileSystem? fileSystem)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
Uri? rekor = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.RekorUrl))
|
||||||
|
{
|
||||||
|
rekor = new Uri(options.RekorUrl, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CosignKeyPairIdentity(
|
||||||
|
PrivateKeyPath: options.PrivateKeyPath!,
|
||||||
|
Password: options.Password,
|
||||||
|
CertificatePath: options.CertificatePath,
|
||||||
|
RekorUrl: rekor,
|
||||||
|
FulcioRootPath: options.FulcioRootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
|
||||||
|
|
||||||
|
public enum OciRegistryAuthMode
|
||||||
|
{
|
||||||
|
Anonymous = 0,
|
||||||
|
Basic = 1,
|
||||||
|
IdentityToken = 2,
|
||||||
|
RefreshToken = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record OciRegistryAuthorization(
|
||||||
|
string? RegistryAuthority,
|
||||||
|
OciRegistryAuthMode Mode,
|
||||||
|
string? Username,
|
||||||
|
string? Password,
|
||||||
|
string? IdentityToken,
|
||||||
|
string? RefreshToken,
|
||||||
|
bool AllowAnonymousFallback)
|
||||||
|
{
|
||||||
|
public static OciRegistryAuthorization Create(OciRegistryAuthenticationOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var mode = OciRegistryAuthMode.Anonymous;
|
||||||
|
string? username = null;
|
||||||
|
string? password = null;
|
||||||
|
string? identityToken = null;
|
||||||
|
string? refreshToken = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.IdentityToken))
|
||||||
|
{
|
||||||
|
mode = OciRegistryAuthMode.IdentityToken;
|
||||||
|
identityToken = options.IdentityToken;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(options.RefreshToken))
|
||||||
|
{
|
||||||
|
mode = OciRegistryAuthMode.RefreshToken;
|
||||||
|
refreshToken = options.RefreshToken;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(options.Username))
|
||||||
|
{
|
||||||
|
mode = OciRegistryAuthMode.Basic;
|
||||||
|
username = options.Username;
|
||||||
|
password = options.Password;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OciRegistryAuthorization(
|
||||||
|
RegistryAuthority: options.RegistryAuthority,
|
||||||
|
Mode: mode,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
IdentityToken: identityToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
AllowAnonymousFallback: options.AllowAnonymousFallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Abstractions;
|
||||||
|
using System.Linq;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
|
||||||
|
public sealed class OciOpenVexAttestationConnectorOptions
|
||||||
|
{
|
||||||
|
public const string HttpClientName = "excititor.connector.oci.openvex.attest";
|
||||||
|
|
||||||
|
public IList<OciImageSubscriptionOptions> Images { get; } = new List<OciImageSubscriptionOptions>();
|
||||||
|
|
||||||
|
public OciRegistryAuthenticationOptions Registry { get; } = new();
|
||||||
|
|
||||||
|
public OciCosignVerificationOptions Cosign { get; } = new();
|
||||||
|
|
||||||
|
public OciOfflineBundleOptions Offline { get; } = new();
|
||||||
|
|
||||||
|
public TimeSpan DiscoveryCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public int MaxParallelResolutions { get; set; } = 4;
|
||||||
|
|
||||||
|
public bool AllowHttpRegistries { get; set; }
|
||||||
|
|
||||||
|
public void Validate(IFileSystem? fileSystem = null)
|
||||||
|
{
|
||||||
|
if (Images.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("At least one OCI image reference must be configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var image in Images)
|
||||||
|
{
|
||||||
|
image.Validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MaxParallelResolutions <= 0 || MaxParallelResolutions > 32)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MaxParallelResolutions must be between 1 and 32.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DiscoveryCacheDuration <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("DiscoveryCacheDuration must be a positive time span.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Registry.Validate();
|
||||||
|
Cosign.Validate(fileSystem);
|
||||||
|
Offline.Validate(fileSystem);
|
||||||
|
|
||||||
|
if (!AllowHttpRegistries && Images.Any(i => i.Reference is not null && i.Reference.StartsWith("http://", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("HTTP (non-TLS) registries are disabled. Enable AllowHttpRegistries to permit them.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OciImageSubscriptionOptions
|
||||||
|
{
|
||||||
|
private OciImageReference? _parsedReference;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the OCI reference (e.g. registry.example.com/repository:tag or registry.example.com/repository@sha256:abcdef).
|
||||||
|
/// </summary>
|
||||||
|
public string? Reference { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional friendly name used in logs when referencing this subscription.
|
||||||
|
/// </summary>
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional file path for an offline attestation bundle associated with this image.
|
||||||
|
/// </summary>
|
||||||
|
public string? OfflineBundlePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for the expected subject digest. When provided, discovery will verify resolved digests match.
|
||||||
|
/// </summary>
|
||||||
|
public string? ExpectedSubjectDigest { get; set; }
|
||||||
|
|
||||||
|
internal OciImageReference? ParsedReference => _parsedReference;
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Reference))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Image Reference is required for OCI OpenVEX attestation connector.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_parsedReference = OciImageReferenceParser.Parse(Reference);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ExpectedSubjectDigest))
|
||||||
|
{
|
||||||
|
if (!ExpectedSubjectDigest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ExpectedSubjectDigest must start with 'sha256:'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ExpectedSubjectDigest.Length != "sha256:".Length + 64)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ExpectedSubjectDigest must contain a 64-character hexadecimal hash.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OciRegistryAuthenticationOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Optional registry authority filter (e.g. registry.example.com:5000). When set it must match image references.
|
||||||
|
/// </summary>
|
||||||
|
public string? RegistryAuthority { get; set; }
|
||||||
|
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
public string? IdentityToken { get; set; }
|
||||||
|
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
public bool AllowAnonymousFallback { get; set; } = true;
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
var hasUser = !string.IsNullOrWhiteSpace(Username);
|
||||||
|
var hasPassword = !string.IsNullOrWhiteSpace(Password);
|
||||||
|
var hasIdentityToken = !string.IsNullOrWhiteSpace(IdentityToken);
|
||||||
|
var hasRefreshToken = !string.IsNullOrWhiteSpace(RefreshToken);
|
||||||
|
|
||||||
|
if (hasIdentityToken && (hasUser || hasPassword))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("IdentityToken cannot be combined with Username/Password for OCI registry authentication.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasRefreshToken && (hasUser || hasPassword))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RefreshToken cannot be combined with Username/Password for OCI registry authentication.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUser != hasPassword)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Username and Password must be provided together for OCI registry authentication.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(RegistryAuthority) && RegistryAuthority.Contains('/', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RegistryAuthority must not contain path segments.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OciCosignVerificationOptions
|
||||||
|
{
|
||||||
|
public CosignCredentialMode Mode { get; set; } = CosignCredentialMode.Keyless;
|
||||||
|
|
||||||
|
public CosignKeylessOptions Keyless { get; } = new();
|
||||||
|
|
||||||
|
public CosignKeyPairOptions KeyPair { get; } = new();
|
||||||
|
|
||||||
|
public bool RequireSignature { get; set; } = true;
|
||||||
|
|
||||||
|
public TimeSpan VerifyTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
public void Validate(IFileSystem? fileSystem = null)
|
||||||
|
{
|
||||||
|
if (VerifyTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("VerifyTimeout must be a positive time span.");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (Mode)
|
||||||
|
{
|
||||||
|
case CosignCredentialMode.None:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CosignCredentialMode.Keyless:
|
||||||
|
Keyless.Validate();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CosignCredentialMode.KeyPair:
|
||||||
|
KeyPair.Validate(fileSystem);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unsupported Cosign credential mode '{Mode}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CosignCredentialMode
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Keyless = 1,
|
||||||
|
KeyPair = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CosignKeylessOptions
|
||||||
|
{
|
||||||
|
public string? Issuer { get; set; }
|
||||||
|
|
||||||
|
public string? Subject { get; set; }
|
||||||
|
|
||||||
|
public string? FulcioUrl { get; set; } = "https://fulcio.sigstore.dev";
|
||||||
|
|
||||||
|
public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev";
|
||||||
|
|
||||||
|
public string? ClientId { get; set; }
|
||||||
|
|
||||||
|
public string? ClientSecret { get; set; }
|
||||||
|
|
||||||
|
public string? Audience { get; set; }
|
||||||
|
|
||||||
|
public string? IdentityToken { get; set; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Issuer))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Cosign keyless Issuer must be provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Subject))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Cosign keyless Subject must be provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(FulcioUrl) && !Uri.TryCreate(FulcioUrl, UriKind.Absolute, out var fulcio))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("FulcioUrl must be an absolute URI when provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out var rekor))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RekorUrl must be an absolute URI when provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ClientSecret) && string.IsNullOrWhiteSpace(ClientId))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Cosign keyless ClientId must be provided when ClientSecret is specified.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CosignKeyPairOptions
|
||||||
|
{
|
||||||
|
public string? PrivateKeyPath { get; set; }
|
||||||
|
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
public string? CertificatePath { get; set; }
|
||||||
|
|
||||||
|
public string? RekorUrl { get; set; }
|
||||||
|
|
||||||
|
public string? FulcioRootPath { get; set; }
|
||||||
|
|
||||||
|
public void Validate(IFileSystem? fileSystem = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PrivateKeyPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("PrivateKeyPath must be provided for Cosign key pair mode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fs = fileSystem ?? new FileSystem();
|
||||||
|
if (!fs.File.Exists(PrivateKeyPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Cosign private key file not found: {PrivateKeyPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(CertificatePath) && !fs.File.Exists(CertificatePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Cosign certificate file not found: {CertificatePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(FulcioRootPath) && !fs.File.Exists(FulcioRootPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Cosign Fulcio root file not found: {FulcioRootPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(RekorUrl) && !Uri.TryCreate(RekorUrl, UriKind.Absolute, out _))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("RekorUrl must be an absolute URI when provided for Cosign key pair mode.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OciOfflineBundleOptions
|
||||||
|
{
|
||||||
|
public string? RootDirectory { get; set; }
|
||||||
|
|
||||||
|
public bool PreferOffline { get; set; }
|
||||||
|
|
||||||
|
public bool AllowNetworkFallback { get; set; } = true;
|
||||||
|
|
||||||
|
public string? DefaultBundleFileName { get; set; } = "openvex-attestations.tgz";
|
||||||
|
|
||||||
|
public bool RequireBundles { get; set; }
|
||||||
|
|
||||||
|
public void Validate(IFileSystem? fileSystem = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(RootDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fs = fileSystem ?? new FileSystem();
|
||||||
|
if (!fs.Directory.Exists(RootDirectory))
|
||||||
|
{
|
||||||
|
if (PreferOffline || RequireBundles)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Offline bundle root directory '{RootDirectory}' does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.Directory.CreateDirectory(RootDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.Abstractions;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
|
||||||
|
public sealed class OciOpenVexAttestationConnectorOptionsValidator : IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>
|
||||||
|
{
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
|
||||||
|
public OciOpenVexAttestationConnectorOptionsValidator(IFileSystem fileSystem)
|
||||||
|
{
|
||||||
|
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Validate(
|
||||||
|
VexConnectorDescriptor descriptor,
|
||||||
|
OciOpenVexAttestationConnectorOptions options,
|
||||||
|
IList<string> errors)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(descriptor);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(errors);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options.Validate(_fileSystem);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using StellaOps.Excititor.Connectors.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
using StellaOps.Excititor.Core;
|
||||||
|
using System.IO.Abstractions;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
|
||||||
|
|
||||||
|
public static class OciOpenVexAttestationConnectorServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddOciOpenVexAttestationConnector(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<OciOpenVexAttestationConnectorOptions>? configure = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.TryAddSingleton<IMemoryCache, MemoryCache>();
|
||||||
|
services.TryAddSingleton<IFileSystem, FileSystem>();
|
||||||
|
|
||||||
|
services.AddOptions<OciOpenVexAttestationConnectorOptions>()
|
||||||
|
.Configure(options =>
|
||||||
|
{
|
||||||
|
configure?.Invoke(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>, OciOpenVexAttestationConnectorOptionsValidator>();
|
||||||
|
services.AddSingleton<OciAttestationDiscoveryService>();
|
||||||
|
services.AddSingleton<OciAttestationFetcher>();
|
||||||
|
services.AddSingleton<IVexConnector, OciOpenVexAttestationConnector>();
|
||||||
|
|
||||||
|
services.AddHttpClient(OciOpenVexAttestationConnectorOptions.HttpClientName, client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest/1.0");
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.cncf.openvex.v1+json");
|
||||||
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
||||||
|
})
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||||
|
{
|
||||||
|
AutomaticDecompression = DecompressionMethods.All,
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
public sealed record OciAttestationDiscoveryResult(
|
||||||
|
ImmutableArray<OciAttestationTarget> Targets,
|
||||||
|
OciRegistryAuthorization RegistryAuthorization,
|
||||||
|
OciCosignAuthority CosignAuthority,
|
||||||
|
bool PreferOffline,
|
||||||
|
bool AllowNetworkFallback);
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO.Abstractions;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
public sealed class OciAttestationDiscoveryService
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _memoryCache;
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly ILogger<OciAttestationDiscoveryService> _logger;
|
||||||
|
|
||||||
|
public OciAttestationDiscoveryService(
|
||||||
|
IMemoryCache memoryCache,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
ILogger<OciAttestationDiscoveryService> logger)
|
||||||
|
{
|
||||||
|
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
|
||||||
|
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OciAttestationDiscoveryResult> LoadAsync(
|
||||||
|
OciOpenVexAttestationConnectorOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var cacheKey = CreateCacheKey(options);
|
||||||
|
if (_memoryCache.TryGetValue(cacheKey, out OciAttestationDiscoveryResult? cached) && cached is not null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Using cached OCI attestation discovery result for {ImageCount} images.", cached.Targets.Length);
|
||||||
|
return Task.FromResult(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets = new List<OciAttestationTarget>(options.Images.Count);
|
||||||
|
foreach (var image in options.Images)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var parsed = image.ParsedReference ?? OciImageReferenceParser.Parse(image.Reference!);
|
||||||
|
var offlinePath = ResolveOfflinePath(options, image, parsed);
|
||||||
|
|
||||||
|
OciOfflineBundleReference? offline = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(offlinePath))
|
||||||
|
{
|
||||||
|
var fullPath = _fileSystem.Path.GetFullPath(offlinePath!);
|
||||||
|
var exists = _fileSystem.File.Exists(fullPath) || _fileSystem.Directory.Exists(fullPath);
|
||||||
|
|
||||||
|
if (!exists && options.Offline.RequireBundles)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Required offline bundle '{fullPath}' for reference '{parsed.Canonical}' was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
offline = new OciOfflineBundleReference(fullPath, exists, image.ExpectedSubjectDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.Add(new OciAttestationTarget(parsed, image.ExpectedSubjectDigest, offline));
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorization = OciRegistryAuthorization.Create(options.Registry);
|
||||||
|
var cosignAuthority = OciCosignAuthorityFactory.Create(options.Cosign, _fileSystem);
|
||||||
|
|
||||||
|
var result = new OciAttestationDiscoveryResult(
|
||||||
|
targets.ToImmutableArray(),
|
||||||
|
authorization,
|
||||||
|
cosignAuthority,
|
||||||
|
options.Offline.PreferOffline,
|
||||||
|
options.Offline.AllowNetworkFallback);
|
||||||
|
|
||||||
|
_memoryCache.Set(cacheKey, result, options.DiscoveryCacheDuration);
|
||||||
|
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveOfflinePath(
|
||||||
|
OciOpenVexAttestationConnectorOptions options,
|
||||||
|
OciImageSubscriptionOptions image,
|
||||||
|
OciImageReference parsed)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(image.OfflineBundlePath))
|
||||||
|
{
|
||||||
|
return image.OfflineBundlePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Offline.RootDirectory))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = options.Offline.RootDirectory!;
|
||||||
|
var segments = new List<string> { SanitizeSegment(parsed.Registry) };
|
||||||
|
|
||||||
|
var repositoryParts = parsed.Repository.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (repositoryParts.Length == 0)
|
||||||
|
{
|
||||||
|
segments.Add(SanitizeSegment(parsed.Repository));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var part in repositoryParts)
|
||||||
|
{
|
||||||
|
segments.Add(SanitizeSegment(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var versionSegment = parsed.Digest is not null
|
||||||
|
? SanitizeSegment(parsed.Digest)
|
||||||
|
: SanitizeSegment(parsed.Tag ?? "latest");
|
||||||
|
|
||||||
|
segments.Add(versionSegment);
|
||||||
|
|
||||||
|
var combined = _fileSystem.Path.Combine(new[] { root }.Concat(segments).ToArray());
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Offline.DefaultBundleFileName))
|
||||||
|
{
|
||||||
|
combined = _fileSystem.Path.Combine(combined, options.Offline.DefaultBundleFileName!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeSegment(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return "_";
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder(value.Length);
|
||||||
|
foreach (var ch in value)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.')
|
||||||
|
{
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Length == 0 ? "_" : builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateCacheKey(OciOpenVexAttestationConnectorOptions options)
|
||||||
|
{
|
||||||
|
using var sha = SHA256.Create();
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("oci-openvex-attest");
|
||||||
|
builder.AppendLine(options.MaxParallelResolutions.ToString());
|
||||||
|
builder.AppendLine(options.AllowHttpRegistries.ToString());
|
||||||
|
builder.AppendLine(options.Offline.PreferOffline.ToString());
|
||||||
|
builder.AppendLine(options.Offline.AllowNetworkFallback.ToString());
|
||||||
|
|
||||||
|
foreach (var image in options.Images)
|
||||||
|
{
|
||||||
|
builder.AppendLine(image.Reference ?? string.Empty);
|
||||||
|
builder.AppendLine(image.ExpectedSubjectDigest ?? string.Empty);
|
||||||
|
builder.AppendLine(image.OfflineBundlePath ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Offline.RootDirectory))
|
||||||
|
{
|
||||||
|
builder.AppendLine(options.Offline.RootDirectory);
|
||||||
|
builder.AppendLine(options.Offline.DefaultBundleFileName ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(options.Registry.RegistryAuthority ?? string.Empty);
|
||||||
|
builder.AppendLine(options.Registry.AllowAnonymousFallback.ToString());
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||||
|
var hashBytes = sha.ComputeHash(bytes);
|
||||||
|
return Convert.ToHexString(hashBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
public sealed record OciAttestationTarget(
|
||||||
|
OciImageReference Image,
|
||||||
|
string? ExpectedSubjectDigest,
|
||||||
|
OciOfflineBundleReference? OfflineBundle);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
public sealed record OciImageReference(string Registry, string Repository, string? Tag, string? Digest, string OriginalReference, string Scheme = "https")
|
||||||
|
{
|
||||||
|
public string Canonical =>
|
||||||
|
Digest is not null
|
||||||
|
? $"{Registry}/{Repository}@{Digest}"
|
||||||
|
: Tag is not null
|
||||||
|
? $"{Registry}/{Repository}:{Tag}"
|
||||||
|
: $"{Registry}/{Repository}";
|
||||||
|
|
||||||
|
public bool HasDigest => !string.IsNullOrWhiteSpace(Digest);
|
||||||
|
|
||||||
|
public bool HasTag => !string.IsNullOrWhiteSpace(Tag);
|
||||||
|
|
||||||
|
public OciImageReference WithDigest(string digest)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(digest))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Digest must be provided.", nameof(digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this with { Digest = digest };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
internal static class OciImageReferenceParser
|
||||||
|
{
|
||||||
|
private static readonly Regex DigestRegex = new(@"^(?<algorithm>[A-Za-z0-9+._-]+):(?<hash>[A-Fa-f0-9]{32,})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
private static readonly Regex RepositoryRegex = new(@"^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
public static OciImageReference Parse(string reference)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reference))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OCI reference cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = reference.Trim();
|
||||||
|
string original = trimmed;
|
||||||
|
|
||||||
|
var scheme = "https";
|
||||||
|
if (trimmed.StartsWith("oci://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
trimmed = trimmed.Substring("oci://".Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
trimmed = trimmed.Substring("https://".Length);
|
||||||
|
scheme = "https";
|
||||||
|
}
|
||||||
|
else if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
trimmed = trimmed.Substring("http://".Length);
|
||||||
|
scheme = "http";
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstSlash = trimmed.IndexOf('/');
|
||||||
|
if (firstSlash <= 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OCI reference '{reference}' must include a registry and repository component.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry = trimmed[..firstSlash];
|
||||||
|
var remainder = trimmed[(firstSlash + 1)..];
|
||||||
|
|
||||||
|
if (!LooksLikeRegistry(registry))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"OCI reference '{reference}' is missing an explicit registry component.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? digest = null;
|
||||||
|
string? tag = null;
|
||||||
|
|
||||||
|
var digestIndex = remainder.IndexOf('@');
|
||||||
|
if (digestIndex >= 0)
|
||||||
|
{
|
||||||
|
digest = remainder[(digestIndex + 1)..];
|
||||||
|
remainder = remainder[..digestIndex];
|
||||||
|
|
||||||
|
if (!DigestRegex.IsMatch(digest))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Digest segment '{digest}' is not a valid OCI digest.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagIndex = remainder.LastIndexOf(':');
|
||||||
|
if (tagIndex >= 0)
|
||||||
|
{
|
||||||
|
tag = remainder[(tagIndex + 1)..];
|
||||||
|
remainder = remainder[..tagIndex];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tag))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OCI tag segment cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag.Contains('/', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OCI tag segment cannot contain '/'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var repository = remainder;
|
||||||
|
if (string.IsNullOrWhiteSpace(repository))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OCI repository segment cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RepositoryRegex.IsMatch(repository))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Repository segment '{repository}' is not valid per OCI distribution rules.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OciImageReference(
|
||||||
|
Registry: registry,
|
||||||
|
Repository: repository,
|
||||||
|
Tag: tag,
|
||||||
|
Digest: digest,
|
||||||
|
OriginalReference: original,
|
||||||
|
Scheme: scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool LooksLikeRegistry(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains('.', StringComparison.Ordinal) || value.Contains(':', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4/IPv6 simplified check
|
||||||
|
if (value.Length >= 3 && char.IsDigit(value[0]))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
public sealed record OciOfflineBundleReference(string Path, bool Exists, string? ExpectedSubjectDigest);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||||
|
|
||||||
|
internal sealed record OciArtifactDescriptor(
|
||||||
|
[property: JsonPropertyName("digest")] string Digest,
|
||||||
|
[property: JsonPropertyName("mediaType")] string MediaType,
|
||||||
|
[property: JsonPropertyName("artifactType")] string? ArtifactType,
|
||||||
|
[property: JsonPropertyName("size")] long Size,
|
||||||
|
[property: JsonPropertyName("annotations")] IReadOnlyDictionary<string, string>? Annotations);
|
||||||
|
|
||||||
|
internal sealed record OciReferrerIndex(
|
||||||
|
[property: JsonPropertyName("referrers")] IReadOnlyList<OciArtifactDescriptor> Referrers);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||||
|
|
||||||
|
public sealed record OciAttestationDocument(
|
||||||
|
Uri SourceUri,
|
||||||
|
ReadOnlyMemory<byte> Content,
|
||||||
|
ImmutableDictionary<string, string> Metadata,
|
||||||
|
string? SubjectDigest,
|
||||||
|
string? ArtifactDigest,
|
||||||
|
string? ArtifactType,
|
||||||
|
string SourceKind);
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Abstractions;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Net.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
using System.Formats.Tar;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||||
|
|
||||||
|
public sealed class OciAttestationFetcher
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly ILogger<OciAttestationFetcher> _logger;
|
||||||
|
|
||||||
|
public OciAttestationFetcher(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IFileSystem fileSystem,
|
||||||
|
ILogger<OciAttestationFetcher> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||||
|
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<OciAttestationDocument> FetchAsync(
|
||||||
|
OciAttestationDiscoveryResult discovery,
|
||||||
|
OciOpenVexAttestationConnectorOptions options,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(discovery);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
foreach (var target in discovery.Targets)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
bool yieldedOffline = false;
|
||||||
|
if (target.OfflineBundle is not null && target.OfflineBundle.Exists)
|
||||||
|
{
|
||||||
|
await foreach (var offlineDocument in ReadOfflineAsync(target, cancellationToken))
|
||||||
|
{
|
||||||
|
yieldedOffline = true;
|
||||||
|
yield return offlineDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!discovery.AllowNetworkFallback)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discovery.PreferOffline && yieldedOffline && !discovery.AllowNetworkFallback)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!discovery.PreferOffline || discovery.AllowNetworkFallback || !yieldedOffline)
|
||||||
|
{
|
||||||
|
await foreach (var registryDocument in FetchFromRegistryAsync(discovery, options, target, cancellationToken))
|
||||||
|
{
|
||||||
|
yield return registryDocument;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async IAsyncEnumerable<OciAttestationDocument> ReadOfflineAsync(
|
||||||
|
OciAttestationTarget target,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var offline = target.OfflineBundle!;
|
||||||
|
var path = _fileSystem.Path.GetFullPath(offline.Path);
|
||||||
|
|
||||||
|
if (!_fileSystem.File.Exists(path))
|
||||||
|
{
|
||||||
|
if (offline.Exists)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Offline bundle {Path} disappeared before processing.", path);
|
||||||
|
}
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var extension = _fileSystem.Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest;
|
||||||
|
|
||||||
|
if (string.Equals(extension, ".json", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(extension, ".dsse", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var bytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
var metadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest);
|
||||||
|
yield return new OciAttestationDocument(
|
||||||
|
new Uri(path, UriKind.Absolute),
|
||||||
|
bytes,
|
||||||
|
metadata,
|
||||||
|
subjectDigest,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"offline");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(extension, ".tgz", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(extension, ".gz", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(extension, ".tar", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await foreach (var document in ReadTarArchiveAsync(target, path, subjectDigest, cancellationToken))
|
||||||
|
{
|
||||||
|
yield return document;
|
||||||
|
}
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: treat as binary blob.
|
||||||
|
var fallbackBytes = await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
var fallbackMetadata = BuildOfflineMetadata(target, path, entryName: null, subjectDigest);
|
||||||
|
yield return new OciAttestationDocument(
|
||||||
|
new Uri(path, UriKind.Absolute),
|
||||||
|
fallbackBytes,
|
||||||
|
fallbackMetadata,
|
||||||
|
subjectDigest,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async IAsyncEnumerable<OciAttestationDocument> ReadTarArchiveAsync(
|
||||||
|
OciAttestationTarget target,
|
||||||
|
string path,
|
||||||
|
string? subjectDigest,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var fileStream = _fileSystem.File.OpenRead(path);
|
||||||
|
Stream archiveStream = fileStream;
|
||||||
|
|
||||||
|
if (path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
archiveStream = new GZipStream(fileStream, CompressionMode.Decompress, leaveOpen: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var tarReader = new TarReader(archiveStream, leaveOpen: false);
|
||||||
|
TarEntry? entry;
|
||||||
|
|
||||||
|
while ((entry = await tarReader.GetNextEntryAsync(copyData: false, cancellationToken).ConfigureAwait(false)) is not null)
|
||||||
|
{
|
||||||
|
if (entry.EntryType is not TarEntryType.RegularFile || entry.DataStream is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var entryStream = entry.DataStream;
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
await entryStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var metadata = BuildOfflineMetadata(target, path, entry.Name, subjectDigest);
|
||||||
|
var sourceUri = new Uri($"{_fileSystem.Path.GetFullPath(path)}#{entry.Name}", UriKind.Absolute);
|
||||||
|
yield return new OciAttestationDocument(
|
||||||
|
sourceUri,
|
||||||
|
buffer.ToArray(),
|
||||||
|
metadata,
|
||||||
|
subjectDigest,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async IAsyncEnumerable<OciAttestationDocument> FetchFromRegistryAsync(
|
||||||
|
OciAttestationDiscoveryResult discovery,
|
||||||
|
OciOpenVexAttestationConnectorOptions options,
|
||||||
|
OciAttestationTarget target,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var registryClient = new OciRegistryClient(
|
||||||
|
_httpClientFactory,
|
||||||
|
_logger,
|
||||||
|
discovery.RegistryAuthorization,
|
||||||
|
options);
|
||||||
|
|
||||||
|
var subjectDigest = target.Image.Digest ?? target.ExpectedSubjectDigest;
|
||||||
|
if (string.IsNullOrWhiteSpace(subjectDigest))
|
||||||
|
{
|
||||||
|
subjectDigest = await registryClient.ResolveDigestAsync(target.Image, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(subjectDigest))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to resolve subject digest for {Reference}; skipping registry fetch.", target.Image.Canonical);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest) &&
|
||||||
|
!string.Equals(target.ExpectedSubjectDigest, subjectDigest, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Resolved digest {Resolved} does not match expected digest {Expected} for {Reference}.",
|
||||||
|
subjectDigest,
|
||||||
|
target.ExpectedSubjectDigest,
|
||||||
|
target.Image.Canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
var descriptors = await registryClient.ListReferrersAsync(target.Image, subjectDigest, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (descriptors.Count == 0)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var descriptor in descriptors)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var document = await registryClient.DownloadAttestationAsync(target.Image, descriptor, subjectDigest, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (document is not null)
|
||||||
|
{
|
||||||
|
yield return document;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableDictionary<string, string> BuildOfflineMetadata(
|
||||||
|
OciAttestationTarget target,
|
||||||
|
string bundlePath,
|
||||||
|
string? entryName,
|
||||||
|
string? subjectDigest)
|
||||||
|
{
|
||||||
|
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||||
|
builder["oci.image.registry"] = target.Image.Registry;
|
||||||
|
builder["oci.image.repository"] = target.Image.Repository;
|
||||||
|
builder["oci.image.reference"] = target.Image.Canonical;
|
||||||
|
if (!string.IsNullOrWhiteSpace(subjectDigest))
|
||||||
|
{
|
||||||
|
builder["oci.image.subjectDigest"] = subjectDigest;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(target.ExpectedSubjectDigest))
|
||||||
|
{
|
||||||
|
builder["oci.image.expectedSubjectDigest"] = target.ExpectedSubjectDigest!;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder["oci.attestation.sourceKind"] = "offline";
|
||||||
|
builder["oci.attestation.source"] = bundlePath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(entryName))
|
||||||
|
{
|
||||||
|
builder["oci.attestation.bundleEntry"] = entryName!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToImmutable();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Authentication;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||||
|
|
||||||
|
internal sealed class OciRegistryClient
|
||||||
|
{
|
||||||
|
private const string ManifestMediaType = "application/vnd.oci.image.manifest.v1+json";
|
||||||
|
private const string ReferrersArtifactType = "application/vnd.dsse.envelope.v1+json";
|
||||||
|
private const string DsseMediaType = "application/vnd.dsse.envelope.v1+json";
|
||||||
|
private const string OpenVexMediaType = "application/vnd.cncf.openvex.v1+json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly OciRegistryAuthorization _authorization;
|
||||||
|
private readonly OciOpenVexAttestationConnectorOptions _options;
|
||||||
|
|
||||||
|
public OciRegistryClient(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger logger,
|
||||||
|
OciRegistryAuthorization authorization,
|
||||||
|
OciOpenVexAttestationConnectorOptions options)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> ResolveDigestAsync(OciImageReference image, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(image);
|
||||||
|
|
||||||
|
if (image.HasDigest)
|
||||||
|
{
|
||||||
|
return image.Digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestUri = BuildRegistryUri(image, $"manifests/{EscapeReference(image.Tag ?? "latest")}");
|
||||||
|
|
||||||
|
async Task<HttpRequestMessage> RequestFactory()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Head, requestUri);
|
||||||
|
request.Headers.Accept.ParseAdd(ManifestMediaType);
|
||||||
|
ApplyAuthentication(request);
|
||||||
|
return await Task.FromResult(request).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to resolve digest for {Reference}; registry returned 404.", image.Canonical);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.Headers.TryGetValues("Docker-Content-Digest", out var values))
|
||||||
|
{
|
||||||
|
var digest = values.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(digest))
|
||||||
|
{
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest may have been returned without digest header; fall back to GET.
|
||||||
|
async Task<HttpRequestMessage> ManifestRequestFactory()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
request.Headers.Accept.ParseAdd(ManifestMediaType);
|
||||||
|
ApplyAuthentication(request);
|
||||||
|
return await Task.FromResult(request).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var manifestResponse = await SendAsync(ManifestRequestFactory, cancellationToken).ConfigureAwait(false);
|
||||||
|
manifestResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
if (manifestResponse.Headers.TryGetValues("Docker-Content-Digest", out var manifestValues))
|
||||||
|
{
|
||||||
|
return manifestValues.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Registry {Registry} did not provide Docker-Content-Digest header for {Reference}.", image.Registry, image.Canonical);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<OciArtifactDescriptor>> ListReferrersAsync(
|
||||||
|
OciImageReference image,
|
||||||
|
string subjectDigest,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(image);
|
||||||
|
ArgumentNullException.ThrowIfNull(subjectDigest);
|
||||||
|
|
||||||
|
var query = $"artifactType={Uri.EscapeDataString(ReferrersArtifactType)}";
|
||||||
|
var requestUri = BuildRegistryUri(image, $"referrers/{subjectDigest}", query);
|
||||||
|
|
||||||
|
async Task<HttpRequestMessage> RequestFactory()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
ApplyAuthentication(request);
|
||||||
|
request.Headers.Accept.ParseAdd("application/json");
|
||||||
|
return await Task.FromResult(request).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Registry returned 404 for referrers on {Subject}.", subjectDigest);
|
||||||
|
return Array.Empty<OciArtifactDescriptor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var index = await JsonSerializer.DeserializeAsync<OciReferrerIndex>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||||
|
return index?.Referrers ?? Array.Empty<OciArtifactDescriptor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OciAttestationDocument?> DownloadAttestationAsync(
|
||||||
|
OciImageReference image,
|
||||||
|
OciArtifactDescriptor descriptor,
|
||||||
|
string subjectDigest,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(image);
|
||||||
|
ArgumentNullException.ThrowIfNull(descriptor);
|
||||||
|
|
||||||
|
if (!IsSupportedDescriptor(descriptor))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestUri = BuildRegistryUri(image, $"blobs/{descriptor.Digest}");
|
||||||
|
|
||||||
|
async Task<HttpRequestMessage> RequestFactory()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
ApplyAuthentication(request);
|
||||||
|
request.Headers.Accept.ParseAdd(descriptor.MediaType ?? "application/octet-stream");
|
||||||
|
return await Task.FromResult(request).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await SendAsync(RequestFactory, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Registry returned 404 while downloading attestation {Digest} for {Subject}.", descriptor.Digest, subjectDigest);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var metadata = BuildMetadata(image, descriptor, "registry", requestUri.ToString(), subjectDigest);
|
||||||
|
return new OciAttestationDocument(
|
||||||
|
requestUri,
|
||||||
|
buffer,
|
||||||
|
metadata,
|
||||||
|
subjectDigest,
|
||||||
|
descriptor.Digest,
|
||||||
|
descriptor.ArtifactType,
|
||||||
|
"registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSupportedDescriptor(OciArtifactDescriptor descriptor)
|
||||||
|
{
|
||||||
|
if (descriptor is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(descriptor.ArtifactType) &&
|
||||||
|
descriptor.ArtifactType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(descriptor.MediaType) &&
|
||||||
|
(descriptor.MediaType.Equals(DsseMediaType, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
descriptor.MediaType.Equals(OpenVexMediaType, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> SendAsync(
|
||||||
|
Func<Task<HttpRequestMessage>> requestFactory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const int maxAttempts = 3;
|
||||||
|
TimeSpan delay = TimeSpan.FromSeconds(1);
|
||||||
|
Exception? lastError = null;
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
using var request = await requestFactory().ConfigureAwait(false);
|
||||||
|
var client = _httpClientFactory.CreateClient(OciOpenVexAttestationConnectorOptions.HttpClientName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
if (_authorization.Mode == OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback)
|
||||||
|
{
|
||||||
|
var message = $"Registry request to {request.RequestUri} was unauthorized and anonymous fallback is disabled.";
|
||||||
|
response.Dispose();
|
||||||
|
throw new HttpRequestException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = new HttpRequestException($"Registry returned 401 Unauthorized for {request.RequestUri}.");
|
||||||
|
}
|
||||||
|
else if ((int)response.StatusCode >= 500 || response.StatusCode == (HttpStatusCode)429)
|
||||||
|
{
|
||||||
|
lastError = new HttpRequestException($"Registry returned status {(int)response.StatusCode} ({response.ReasonPhrase}) for {request.RequestUri}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
lastError = ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts)
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||||
|
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpRequestException("Failed to execute OCI registry request after multiple attempts.", lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAuthentication(HttpRequestMessage request)
|
||||||
|
{
|
||||||
|
switch (_authorization.Mode)
|
||||||
|
{
|
||||||
|
case OciRegistryAuthMode.Basic when
|
||||||
|
!string.IsNullOrEmpty(_authorization.Username) &&
|
||||||
|
!string.IsNullOrEmpty(_authorization.Password):
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_authorization.Username}:{_authorization.Password}"));
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
break;
|
||||||
|
case OciRegistryAuthMode.IdentityToken when !string.IsNullOrWhiteSpace(_authorization.IdentityToken):
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.IdentityToken);
|
||||||
|
break;
|
||||||
|
case OciRegistryAuthMode.RefreshToken when !string.IsNullOrWhiteSpace(_authorization.RefreshToken):
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authorization.RefreshToken);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (_authorization.Mode != OciRegistryAuthMode.Anonymous && !_authorization.AllowAnonymousFallback)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No authentication header applied for request to {Uri} (mode {Mode}).", request.RequestUri, _authorization.Mode);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri BuildRegistryUri(OciImageReference image, string relativePath, string? query = null)
|
||||||
|
{
|
||||||
|
var scheme = image.Scheme;
|
||||||
|
if (!string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) && !_options.AllowHttpRegistries)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"HTTP access to registry '{image.Registry}' is disabled. Set AllowHttpRegistries to true to enable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new UriBuilder($"{scheme}://{image.Registry}")
|
||||||
|
{
|
||||||
|
Path = $"v2/{BuildRepositoryPath(image.Repository)}/{relativePath}"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
builder.Query = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildRepositoryPath(string repository)
|
||||||
|
{
|
||||||
|
var segments = repository.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
return string.Join('/', segments.Select(Uri.EscapeDataString));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeReference(string reference)
|
||||||
|
{
|
||||||
|
return Uri.EscapeDataString(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableDictionary<string, string> BuildMetadata(
|
||||||
|
OciImageReference image,
|
||||||
|
OciArtifactDescriptor descriptor,
|
||||||
|
string sourceKind,
|
||||||
|
string sourcePath,
|
||||||
|
string subjectDigest)
|
||||||
|
{
|
||||||
|
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||||
|
builder["oci.image.registry"] = image.Registry;
|
||||||
|
builder["oci.image.repository"] = image.Repository;
|
||||||
|
builder["oci.image.reference"] = image.Canonical;
|
||||||
|
builder["oci.image.subjectDigest"] = subjectDigest;
|
||||||
|
builder["oci.attestation.sourceKind"] = sourceKind;
|
||||||
|
builder["oci.attestation.source"] = sourcePath;
|
||||||
|
builder["oci.attestation.artifactDigest"] = descriptor.Digest;
|
||||||
|
builder["oci.attestation.mediaType"] = descriptor.MediaType ?? string.Empty;
|
||||||
|
builder["oci.attestation.artifactType"] = descriptor.ArtifactType ?? string.Empty;
|
||||||
|
builder["oci.attestation.size"] = descriptor.Size.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
if (descriptor.Annotations is not null)
|
||||||
|
{
|
||||||
|
foreach (var annotation in descriptor.Annotations)
|
||||||
|
{
|
||||||
|
builder[$"oci.attestation.annotations.{annotation.Key}"] = annotation.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToImmutable();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Excititor.Connectors.Abstractions;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
|
||||||
|
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
|
||||||
|
using StellaOps.Excititor.Core;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
|
||||||
|
|
||||||
|
public sealed class OciOpenVexAttestationConnector : VexConnectorBase
|
||||||
|
{
|
||||||
|
private static readonly VexConnectorDescriptor StaticDescriptor = new(
|
||||||
|
id: "excititor:oci.openvex.attest",
|
||||||
|
kind: VexProviderKind.Attestation,
|
||||||
|
displayName: "OCI OpenVEX Attestations")
|
||||||
|
{
|
||||||
|
Tags = ImmutableArray.Create("oci", "openvex", "attestation", "cosign", "offline"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly OciAttestationDiscoveryService _discoveryService;
|
||||||
|
private readonly OciAttestationFetcher _fetcher;
|
||||||
|
private readonly IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>> _validators;
|
||||||
|
|
||||||
|
private OciOpenVexAttestationConnectorOptions? _options;
|
||||||
|
private OciAttestationDiscoveryResult? _discovery;
|
||||||
|
|
||||||
|
public OciOpenVexAttestationConnector(
|
||||||
|
OciAttestationDiscoveryService discoveryService,
|
||||||
|
OciAttestationFetcher fetcher,
|
||||||
|
ILogger<OciOpenVexAttestationConnector> logger,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
IEnumerable<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>? validators = null)
|
||||||
|
: base(StaticDescriptor, logger, timeProvider)
|
||||||
|
{
|
||||||
|
_discoveryService = discoveryService ?? throw new ArgumentNullException(nameof(discoveryService));
|
||||||
|
_fetcher = fetcher ?? throw new ArgumentNullException(nameof(fetcher));
|
||||||
|
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<OciOpenVexAttestationConnectorOptions>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_options = VexConnectorOptionsBinder.Bind(
|
||||||
|
Descriptor,
|
||||||
|
settings,
|
||||||
|
validators: _validators);
|
||||||
|
|
||||||
|
_discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
LogConnectorEvent(LogLevel.Information, "validate", "Resolved OCI attestation targets.", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["targets"] = _discovery.Targets.Length,
|
||||||
|
["offlinePreferred"] = _discovery.PreferOffline,
|
||||||
|
["allowNetworkFallback"] = _discovery.AllowNetworkFallback,
|
||||||
|
["authMode"] = _discovery.RegistryAuthorization.Mode.ToString(),
|
||||||
|
["cosignMode"] = _discovery.CosignAuthority.Mode.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
if (_options is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Connector must be validated before fetch operations.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_discovery is null)
|
||||||
|
{
|
||||||
|
_discovery = await _discoveryService.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var documentCount = 0;
|
||||||
|
await foreach (var document in _fetcher.FetchAsync(_discovery, _options, cancellationToken))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var verificationDocument = CreateRawDocument(
|
||||||
|
VexDocumentFormat.OciAttestation,
|
||||||
|
document.SourceUri,
|
||||||
|
document.Content,
|
||||||
|
document.Metadata);
|
||||||
|
|
||||||
|
var signatureMetadata = await context.SignatureVerifier.VerifyAsync(verificationDocument, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (signatureMetadata is not null)
|
||||||
|
{
|
||||||
|
LogConnectorEvent(LogLevel.Debug, "signature", "Signature metadata captured for attestation.", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["subject"] = signatureMetadata.Subject,
|
||||||
|
["type"] = signatureMetadata.Type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var enrichedMetadata = BuildProvenanceMetadata(document, signatureMetadata);
|
||||||
|
var rawDocument = CreateRawDocument(
|
||||||
|
VexDocumentFormat.OciAttestation,
|
||||||
|
document.SourceUri,
|
||||||
|
document.Content,
|
||||||
|
enrichedMetadata);
|
||||||
|
|
||||||
|
await context.RawSink.StoreAsync(rawDocument, cancellationToken).ConfigureAwait(false);
|
||||||
|
documentCount++;
|
||||||
|
yield return rawDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogConnectorEvent(LogLevel.Information, "fetch", "OCI attestation fetch completed.", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["documents"] = documentCount,
|
||||||
|
["since"] = context.Since?.ToString("O"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException("Attestation documents rely on dedicated normalizers, to be wired in EXCITITOR-CONN-OCI-01-002.");
|
||||||
|
|
||||||
|
public OciAttestationDiscoveryResult? GetCachedDiscovery() => _discovery;
|
||||||
|
|
||||||
|
private ImmutableDictionary<string, string> BuildProvenanceMetadata(OciAttestationDocument document, VexSignatureMetadata? signature)
|
||||||
|
{
|
||||||
|
var builder = document.Metadata.ToBuilder();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(document.SourceKind))
|
||||||
|
{
|
||||||
|
builder["vex.provenance.sourceKind"] = document.SourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(document.SubjectDigest))
|
||||||
|
{
|
||||||
|
builder["vex.provenance.subjectDigest"] = document.SubjectDigest!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(document.ArtifactDigest))
|
||||||
|
{
|
||||||
|
builder["vex.provenance.artifactDigest"] = document.ArtifactDigest!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(document.ArtifactType))
|
||||||
|
{
|
||||||
|
builder["vex.provenance.artifactType"] = document.ArtifactType!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_discovery is not null)
|
||||||
|
{
|
||||||
|
builder["vex.provenance.registryAuthMode"] = _discovery.RegistryAuthorization.Mode.ToString();
|
||||||
|
var registryAuthority = _discovery.RegistryAuthorization.RegistryAuthority;
|
||||||
|
if (string.IsNullOrWhiteSpace(registryAuthority))
|
||||||
|
{
|
||||||
|
if (builder.TryGetValue("oci.image.registry", out var metadataRegistry) && !string.IsNullOrWhiteSpace(metadataRegistry))
|
||||||
|
{
|
||||||
|
registryAuthority = metadataRegistry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(registryAuthority))
|
||||||
|
{
|
||||||
|
builder["vex.provenance.registryAuthority"] = registryAuthority!;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder["vex.provenance.cosign.mode"] = _discovery.CosignAuthority.Mode.ToString();
|
||||||
|
|
||||||
|
if (_discovery.CosignAuthority.Keyless is not null)
|
||||||
|
{
|
||||||
|
var keyless = _discovery.CosignAuthority.Keyless;
|
||||||
|
builder["vex.provenance.cosign.issuer"] = keyless!.Issuer;
|
||||||
|
builder["vex.provenance.cosign.subject"] = keyless.Subject;
|
||||||
|
if (keyless.FulcioUrl is not null)
|
||||||
|
{
|
||||||
|
builder["vex.provenance.cosign.fulcioUrl"] = keyless.FulcioUrl!.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyless.RekorUrl is not null)
|
||||||
|
{
|
||||||
|
builder["vex.provenance.cosign.rekorUrl"] = keyless.RekorUrl!.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_discovery.CosignAuthority.KeyPair is not null)
|
||||||
|
{
|
||||||
|
var keyPair = _discovery.CosignAuthority.KeyPair;
|
||||||
|
builder["vex.provenance.cosign.keyPair"] = "true";
|
||||||
|
if (keyPair!.RekorUrl is not null)
|
||||||
|
{
|
||||||
|
builder["vex.provenance.cosign.rekorUrl"] = keyPair.RekorUrl!.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature is not null)
|
||||||
|
{
|
||||||
|
builder["vex.signature.type"] = signature.Type;
|
||||||
|
if (!string.IsNullOrWhiteSpace(signature.Subject))
|
||||||
|
{
|
||||||
|
builder["vex.signature.subject"] = signature.Subject!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(signature.Issuer))
|
||||||
|
{
|
||||||
|
builder["vex.signature.issuer"] = signature.Issuer!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(signature.KeyId))
|
||||||
|
{
|
||||||
|
builder["vex.signature.keyId"] = signature.KeyId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature.VerifiedAt is not null)
|
||||||
|
{
|
||||||
|
builder["vex.signature.verifiedAt"] = signature.VerifiedAt.Value.ToString("O");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(signature.TransparencyLogReference))
|
||||||
|
{
|
||||||
|
builder["vex.signature.transparencyLogReference"] = signature.TransparencyLogReference!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToImmutable();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<WarningsNotAsErrors>NU1903</WarningsNotAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\StellaOps.Excititor.Connectors.Abstractions\StellaOps.Excititor.Connectors.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||||
|
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -2,6 +2,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|
|||||||
# TASKS
|
# TASKS
|
||||||
| Task | Owner(s) | Depends on | Notes |
|
| Task | Owner(s) | Depends on | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|EXCITITOR-CONN-OCI-01-001 – OCI discovery & auth plumbing|Team Excititor Connectors – OCI|EXCITITOR-CONN-ABS-01-001|TODO – Resolve OCI references, configure cosign auth (keyless/keyed), and support offline attestation bundles.|
|
|EXCITITOR-CONN-OCI-01-001 – OCI discovery & auth plumbing|Team Excititor Connectors – OCI|EXCITITOR-CONN-ABS-01-001|DONE (2025-10-18) – Added connector skeleton, options/validators, discovery caching, cosign/auth descriptors, offline bundle resolution, DI wiring, and regression tests.|
|
||||||
|EXCITITOR-CONN-OCI-01-002 – Attestation fetch & verify loop|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-001, EXCITITOR-ATTEST-01-002|TODO – Download DSSE attestations, trigger verification, handle retries/backoff, and persist raw statements with metadata.|
|
|EXCITITOR-CONN-OCI-01-002 – Attestation fetch & verify loop|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-001, EXCITITOR-ATTEST-01-002|DONE (2025-10-18) – Added offline/registry fetch services, DSSE retrieval with retries, signature verification callout, and raw persistence coverage.|
|
||||||
|EXCITITOR-CONN-OCI-01-003 – Provenance metadata & policy hooks|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-002, EXCITITOR-POLICY-01-001|TODO – Emit provenance hints (image, subject digest, issuer) and trust metadata for policy weighting/logging.|
|
|EXCITITOR-CONN-OCI-01-003 – Provenance metadata & policy hooks|Team Excititor Connectors – OCI|EXCITITOR-CONN-OCI-01-002, EXCITITOR-POLICY-01-001|DONE (2025-10-18) – Enriched attestation metadata with provenance hints, cosign expectations, registry auth context, and signature diagnostics for policy consumption.|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# StellaOps Mirror VEX Connector Task Board (Sprint 7)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| EXCITITOR-CONN-STELLA-07-001 | TODO | Excititor Connectors – Stella | EXCITITOR-EXPORT-01-007 | Implement mirror fetch client consuming `https://<domain>.stella-ops.org/excititor/exports/index.json`, validating signatures/digests, storing raw consensus bundles with provenance. | Fetch job downloads mirror manifest, verifies DSSE/signature, stores raw documents + provenance; unit tests cover happy path and tampered manifest failure. |
|
||||||
|
| EXCITITOR-CONN-STELLA-07-002 | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-001 | Normalize mirror bundles into VexClaim sets referencing original provider metadata and mirror provenance. | Normalizer emits VexClaims with mirror provenance + policy metadata, fixtures assert deterministic output parity vs local exports. |
|
||||||
|
| EXCITITOR-CONN-STELLA-07-003 | TODO | Excititor Connectors – Stella | EXCITITOR-CONN-STELLA-07-002 | Implement incremental cursor handling per-export digest, support resume, and document configuration for downstream Excititor mirrors. | Connector resumes from last export digest, handles delta/export rotation, docs show configuration; integration test covers resume + new export ingest. |
|
||||||
@@ -7,3 +7,5 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|
|||||||
|EXCITITOR-EXPORT-01-003 – Artifact store adapters|Team Excititor Export|EXCITITOR-EXPORT-01-001|**DONE (2025-10-16)** – Implemented multi-store pipeline with filesystem, S3-compatible, and offline bundle adapters (hash verification + manifest/zip output) plus unit coverage and DI hooks.|
|
|EXCITITOR-EXPORT-01-003 – Artifact store adapters|Team Excititor Export|EXCITITOR-EXPORT-01-001|**DONE (2025-10-16)** – Implemented multi-store pipeline with filesystem, S3-compatible, and offline bundle adapters (hash verification + manifest/zip output) plus unit coverage and DI hooks.|
|
||||||
|EXCITITOR-EXPORT-01-004 – Attestation handoff integration|Team Excititor Export|EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|**DONE (2025-10-17)** – Export engine now invokes attestation client, logs diagnostics, and persists Rekor/envelope metadata on manifests; regression coverage added in `ExportEngineTests.ExportAsync_AttachesAttestationMetadata`.|
|
|EXCITITOR-EXPORT-01-004 – Attestation handoff integration|Team Excititor Export|EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|**DONE (2025-10-17)** – Export engine now invokes attestation client, logs diagnostics, and persists Rekor/envelope metadata on manifests; regression coverage added in `ExportEngineTests.ExportAsync_AttachesAttestationMetadata`.|
|
||||||
|EXCITITOR-EXPORT-01-005 – Score & resolve envelope surfaces|Team Excititor Export|EXCITITOR-EXPORT-01-004, EXCITITOR-CORE-02-001|TODO – Emit consensus+score envelopes in export manifests, include policy/scoring digests, and update offline bundle/ORAS layouts to carry signed VEX responses.|
|
|EXCITITOR-EXPORT-01-005 – Score & resolve envelope surfaces|Team Excititor Export|EXCITITOR-EXPORT-01-004, EXCITITOR-CORE-02-001|TODO – Emit consensus+score envelopes in export manifests, include policy/scoring digests, and update offline bundle/ORAS layouts to carry signed VEX responses.|
|
||||||
|
|EXCITITOR-EXPORT-01-006 – Quiet provenance packaging|Team Excititor Export|EXCITITOR-EXPORT-01-005, POLICY-CORE-09-005|TODO – Attach `quietedBy` statement IDs, signers, and justification codes to exports/offline bundles, mirror metadata into attested manifest, and add regression fixtures.|
|
||||||
|
|EXCITITOR-EXPORT-01-007 – Mirror bundle + domain manifest|Team Excititor Export|EXCITITOR-EXPORT-01-006|TODO – Create per-domain mirror bundles with consensus/score artifacts, publish signed index for downstream Excititor sync, and ensure deterministic digests + fixtures.|
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|
|||||||
|EXCITITOR-WEB-01-002 – Ingest & reconcile endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001|TODO – Implement `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile` with token scope enforcement and structured run telemetry.|
|
|EXCITITOR-WEB-01-002 – Ingest & reconcile endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001|TODO – Implement `/excititor/init`, `/excititor/ingest/run`, `/excititor/ingest/resume`, `/excititor/reconcile` with token scope enforcement and structured run telemetry.|
|
||||||
|EXCITITOR-WEB-01-003 – Export & verify endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|TODO – Add `/excititor/export`, `/excititor/export/{id}`, `/excititor/export/{id}/download`, `/excititor/verify`, returning artifact + attestation metadata with cache awareness.|
|
|EXCITITOR-WEB-01-003 – Export & verify endpoints|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|TODO – Add `/excititor/export`, `/excititor/export/{id}`, `/excititor/export/{id}/download`, `/excititor/verify`, returning artifact + attestation metadata with cache awareness.|
|
||||||
|EXCITITOR-WEB-01-004 – Resolve API & signed responses|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-ATTEST-01-002|TODO – Deliver `/excititor/resolve` (subject/context), return consensus + score envelopes, attach cosign/Rekor metadata, and document auth + rate guardrails.|
|
|EXCITITOR-WEB-01-004 – Resolve API & signed responses|Team Excititor WebService|EXCITITOR-WEB-01-001, EXCITITOR-ATTEST-01-002|TODO – Deliver `/excititor/resolve` (subject/context), return consensus + score envelopes, attach cosign/Rekor metadata, and document auth + rate guardrails.|
|
||||||
|
|EXCITITOR-WEB-01-005 – Mirror distribution endpoints|Team Excititor WebService|EXCITITOR-EXPORT-01-007, DEVOPS-MIRROR-08-001|TODO – Provide domain-scoped mirror index/download APIs for consensus exports, enforce quota/auth, and document sync workflow for downstream Excititor deployments.|
|
||||||
|
|||||||
4
src/StellaOps.Notify.Connectors.Email/AGENTS.md
Normal file
4
src/StellaOps.Notify.Connectors.Email/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Connectors.Email — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implement SMTP connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/StellaOps.Notify.Connectors.Email/TASKS.md
Normal file
7
src/StellaOps.Notify.Connectors.Email/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notify Email Connector Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-CONN-EMAIL-15-701 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement SMTP connector with STARTTLS/implicit TLS support, HTML+text rendering, attachment policy enforcement. | Integration tests with SMTP stub pass; TLS enforced; attachments blocked per policy. |
|
||||||
|
| NOTIFY-CONN-EMAIL-15-702 | TODO | Notify Connectors Guild | NOTIFY-CONN-EMAIL-15-701 | Add DKIM signing optional support and health/test-send flows. | DKIM optional config verified; test-send passes; secrets handled securely. |
|
||||||
|
| NOTIFY-CONN-EMAIL-15-703 | TODO | Notify Connectors Guild | NOTIFY-CONN-EMAIL-15-702 | Package Email connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/email/`; restart validation passes. |
|
||||||
4
src/StellaOps.Notify.Connectors.Slack/AGENTS.md
Normal file
4
src/StellaOps.Notify.Connectors.Slack/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Connectors.Slack — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Deliver Slack connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/StellaOps.Notify.Connectors.Slack/TASKS.md
Normal file
7
src/StellaOps.Notify.Connectors.Slack/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notify Slack Connector Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-CONN-SLACK-15-501 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement Slack connector with bot token auth, message rendering (blocks), rate limit handling, retries/backoff. | Integration tests stub Slack API; retries/jitter validated; 429 handling documented. |
|
||||||
|
| NOTIFY-CONN-SLACK-15-502 | TODO | Notify Connectors Guild | NOTIFY-CONN-SLACK-15-501 | Health check & test-send support with minimal scopes and redacted tokens. | `/channels/{id}/test` hitting Slack stub passes; secrets never logged; health endpoint returns diagnostics. |
|
||||||
|
| NOTIFY-CONN-SLACK-15-503 | TODO | Notify Connectors Guild | NOTIFY-CONN-SLACK-15-502 | Package Slack connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/slack/`; restart validation passes. |
|
||||||
4
src/StellaOps.Notify.Connectors.Teams/AGENTS.md
Normal file
4
src/StellaOps.Notify.Connectors.Teams/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Connectors.Teams — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implement Microsoft Teams connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/StellaOps.Notify.Connectors.Teams/TASKS.md
Normal file
7
src/StellaOps.Notify.Connectors.Teams/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notify Teams Connector Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-CONN-TEAMS-15-601 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement Teams connector using Adaptive Cards 1.5, handle webhook auth, size limits, retries. | Adaptive card payloads validated; 413/429 handling implemented; integration tests cover success/fail. |
|
||||||
|
| NOTIFY-CONN-TEAMS-15-602 | TODO | Notify Connectors Guild | NOTIFY-CONN-TEAMS-15-601 | Provide health/test-send support with fallback text for legacy clients. | Test-send returns card preview; fallback text logged; docs updated. |
|
||||||
|
| NOTIFY-CONN-TEAMS-15-603 | TODO | Notify Connectors Guild | NOTIFY-CONN-TEAMS-15-602 | Package Teams connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/teams/`; restart validation passes. |
|
||||||
4
src/StellaOps.Notify.Connectors.Webhook/AGENTS.md
Normal file
4
src/StellaOps.Notify.Connectors.Webhook/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Connectors.Webhook — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implement generic webhook connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/StellaOps.Notify.Connectors.Webhook/TASKS.md
Normal file
7
src/StellaOps.Notify.Connectors.Webhook/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notify Webhook Connector Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-CONN-WEBHOOK-15-801 | TODO | Notify Connectors Guild | NOTIFY-ENGINE-15-303 | Implement webhook connector: JSON payload, signature (HMAC/Ed25519), retries/backoff, status code handling. | Integration tests with webhook stub validate signatures, retries, error handling; payload schema documented. |
|
||||||
|
| NOTIFY-CONN-WEBHOOK-15-802 | TODO | Notify Connectors Guild | NOTIFY-CONN-WEBHOOK-15-801 | Health/test-send support with signature validation hints and secret management. | Test-send returns success with sample payload; docs include verification guide; secrets never logged. |
|
||||||
|
| NOTIFY-CONN-WEBHOOK-15-803 | TODO | Notify Connectors Guild | NOTIFY-CONN-WEBHOOK-15-802 | Package Webhook connector as restart-time plug-in (manifest + host registration). | Plugin manifest added; host loads connector from `plugins/notify/webhook/`; restart validation passes. |
|
||||||
4
src/StellaOps.Notify.Engine/AGENTS.md
Normal file
4
src/StellaOps.Notify.Engine/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Engine — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Deliver rule evaluation, digest, and rendering logic per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
8
src/StellaOps.Notify.Engine/TASKS.md
Normal file
8
src/StellaOps.Notify.Engine/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Notify Engine Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-ENGINE-15-301 | TODO | Notify Engine Guild | NOTIFY-MODELS-15-101 | Rules evaluation core: tenant/kind filters, severity/delta gates, VEX gating, throttling, idempotency key generation. | Unit tests cover rule permutations; idempotency keys deterministic; documentation updated. |
|
||||||
|
| NOTIFY-ENGINE-15-302 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-301 | Action planner + digest coalescer with window management and dedupe per architecture §4. | Digest windows tested; throttles and digests recorded; metrics counters exposed. |
|
||||||
|
| NOTIFY-ENGINE-15-303 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-302 | Template rendering engine (Slack, Teams, Email, Webhook) with helpers and i18n support. | Rendering fixtures validated; helpers documented; deterministic output proven via golden tests. |
|
||||||
|
| NOTIFY-ENGINE-15-304 | TODO | Notify Engine Guild | NOTIFY-ENGINE-15-303 | Test-send sandbox + preview utilities for WebService. | Preview/test functions validated; sample outputs returned; no state persisted. |
|
||||||
4
src/StellaOps.Notify.Models/AGENTS.md
Normal file
4
src/StellaOps.Notify.Models/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Models — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Define Notify DTOs and contracts per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/StellaOps.Notify.Models/TASKS.md
Normal file
7
src/StellaOps.Notify.Models/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notify Models Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-MODELS-15-101 | TODO | Notify Models Guild | — | Define core DTOs (Rule, Channel, Template, Event envelope, Delivery) with validation helpers and canonical JSON serialization. | DTOs merged with tests; documented; serialization deterministic. |
|
||||||
|
| NOTIFY-MODELS-15-102 | TODO | Notify Models Guild | NOTIFY-MODELS-15-101 | Publish schema docs + sample payloads for channels, rules, events (used by UI + connectors). | Markdown/JSON schema generated; linked in docs; integration tests reference samples. |
|
||||||
|
| NOTIFY-MODELS-15-103 | TODO | Notify Models Guild | NOTIFY-MODELS-15-101 | Provide versioning and migration helpers (e.g., rule evolution, template revisions). | Migration helpers implemented; tests cover upgrade/downgrade; guidance captured in docs. |
|
||||||
4
src/StellaOps.Notify.Queue/AGENTS.md
Normal file
4
src/StellaOps.Notify.Queue/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Queue — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Provide event & delivery queues for Notify per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
7
src/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj
Normal file
7
src/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/StellaOps.Notify.Queue/TASKS.md
Normal file
7
src/StellaOps.Notify.Queue/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notify Queue Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-QUEUE-15-401 | TODO | Notify Queue Guild | NOTIFY-MODELS-15-101 | Build queue abstraction + Redis Streams adapter with ack/claim APIs, idempotency tokens, serialization contracts. | Adapter integration tests cover enqueue/dequeue/ack; ordering preserved; idempotency tokens supported. |
|
||||||
|
| NOTIFY-QUEUE-15-402 | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Add NATS JetStream adapter with configuration binding, health probes, failover. | Health endpoints verified; failover documented; integration tests exercise both adapters. |
|
||||||
|
| NOTIFY-QUEUE-15-403 | TODO | Notify Queue Guild | NOTIFY-QUEUE-15-401 | Delivery queue for channel actions with retry schedules, poison queues, and metrics instrumentation. | Delivery queue integration tests cover retries/dead-letter; metrics/logging emitted per spec. |
|
||||||
4
src/StellaOps.Notify.Storage.Mongo/AGENTS.md
Normal file
4
src/StellaOps.Notify.Storage.Mongo/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Storage.Mongo — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implement Mongo persistence (rules, channels, deliveries, digests, locks, audit) per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
7
src/StellaOps.Notify.Storage.Mongo/TASKS.md
Normal file
7
src/StellaOps.Notify.Storage.Mongo/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Notify Storage Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-STORAGE-15-201 | TODO | Notify Storage Guild | NOTIFY-MODELS-15-101 | Create Mongo schemas/collections (rules, channels, deliveries, digests, locks, audit) with indexes per architecture §7. | Migration scripts authored; indexes tested; integration tests cover CRUD/read paths. |
|
||||||
|
| NOTIFY-STORAGE-15-202 | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-201 | Implement repositories/services with tenant scoping, soft deletes, TTL, causal consistency (majority) options. | Repositories unit-tested; soft delete + TTL validated; majority read/write configuration documented. |
|
||||||
|
| NOTIFY-STORAGE-15-203 | TODO | Notify Storage Guild | NOTIFY-STORAGE-15-201 | Delivery history retention + query APIs (paging, filters). | History queries return expected data; paging verified; docs updated. |
|
||||||
4
src/StellaOps.Notify.WebService/AGENTS.md
Normal file
4
src/StellaOps.Notify.WebService/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.WebService — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Implement Notify control plane per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
8
src/StellaOps.Notify.WebService/TASKS.md
Normal file
8
src/StellaOps.Notify.WebService/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Notify WebService Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-WEB-15-101 | TODO | Notify WebService Guild | NOTIFY-MODELS-15-101 | Bootstrap minimal API host with Authority auth, health endpoints, and plug-in discovery per architecture. | Service starts with config validation, `/healthz`/`/readyz` pass, plug-ins loaded at restart. |
|
||||||
|
| NOTIFY-WEB-15-102 | TODO | Notify WebService Guild | NOTIFY-WEB-15-101 | Rules/channel/template CRUD endpoints with tenant scoping, validation, audit logging. | CRUD endpoints tested; invalid inputs rejected; audit entries persisted. |
|
||||||
|
| NOTIFY-WEB-15-103 | TODO | Notify WebService Guild | NOTIFY-WEB-15-102 | Delivery history + test-send endpoints with rate limits. | `/deliveries` and `/channels/{id}/test` tested; rate limits enforced. |
|
||||||
|
| NOTIFY-WEB-15-104 | TODO | Notify WebService Guild | NOTIFY-STORAGE-15-201, NOTIFY-QUEUE-15-401 | Configuration binding for Mongo/queue/secrets; startup diagnostics. | Misconfiguration fails fast; diagnostics logged; integration tests cover env overrides. |
|
||||||
4
src/StellaOps.Notify.Worker/AGENTS.md
Normal file
4
src/StellaOps.Notify.Worker/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# StellaOps.Notify.Worker — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Consume events, evaluate rules, and dispatch deliveries per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
8
src/StellaOps.Notify.Worker/TASKS.md
Normal file
8
src/StellaOps.Notify.Worker/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Notify Worker Task Board (Sprint 15)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| NOTIFY-WORKER-15-201 | TODO | Notify Worker Guild | NOTIFY-QUEUE-15-401 | Implement bus subscription + leasing loop with correlation IDs, backoff, dead-letter handling (§1–§5). | Worker consumes events from queue, ack/retry behaviour proven in integration tests; logs include correlation IDs. |
|
||||||
|
| NOTIFY-WORKER-15-202 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-301 | Wire rules evaluation pipeline (tenant scoping, filters, throttles, digests, idempotency) with deterministic decisions. | Evaluation unit tests cover rule combinations; throttles/digests produce expected suppression; idempotency keys validated. |
|
||||||
|
| NOTIFY-WORKER-15-203 | TODO | Notify Worker Guild | NOTIFY-ENGINE-15-302 | Channel dispatch orchestration: invoke connectors, manage retries/jitter, record delivery outcomes. | Connector mocks show retries/backoff; delivery results stored; metrics incremented per outcome. |
|
||||||
|
| NOTIFY-WORKER-15-204 | TODO | Notify Worker Guild | NOTIFY-WORKER-15-203 | Metrics/telemetry: `notify.sent_total`, `notify.dropped_total`, latency histograms, tracing integration. | Metrics emitted per spec; OTLP spans annotated; dashboards documented. |
|
||||||
12
src/StellaOps.Policy/AGENTS.md
Normal file
12
src/StellaOps.Policy/AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# StellaOps.Policy — Agent Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Deliver the policy engine outlined in `docs/ARCHITECTURE_SCANNER.md` and related prose:
|
||||||
|
- Define YAML schema (ignore rules, VEX inclusion/exclusion, vendor precedence, license gates).
|
||||||
|
- Provide policy snapshot storage with revision digests and diagnostics.
|
||||||
|
- Offer preview APIs to compare policy impacts on existing reports.
|
||||||
|
|
||||||
|
## Expectations
|
||||||
|
- Coordinate with Scanner.WebService, Feedser, Vexer, UI, Notify.
|
||||||
|
- Maintain deterministic serialization and unit tests for precedence rules.
|
||||||
|
- Update `TASKS.md` and broadcast contract changes.
|
||||||
7
src/StellaOps.Policy/StellaOps.Policy.csproj
Normal file
7
src/StellaOps.Policy/StellaOps.Policy.csproj
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
13
src/StellaOps.Policy/TASKS.md
Normal file
13
src/StellaOps.Policy/TASKS.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Policy Engine Task Board (Sprint 9)
|
||||||
|
|
||||||
|
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||||
|
|----|--------|----------|------------|-------------|---------------|
|
||||||
|
| POLICY-CORE-09-001 | TODO | Policy Guild | SCANNER-WEB-09-101 | Define YAML schema/binder, diagnostics, CLI validation for policy files. | Schema doc published; binder loads sample policy; validation errors actionable. |
|
||||||
|
| POLICY-CORE-09-002 | TODO | Policy Guild | POLICY-CORE-09-001 | Implement policy snapshot store + revision digests + audit logging. | Snapshots persisted with digest; tests compare revisions; audit entries created. |
|
||||||
|
| POLICY-CORE-09-003 | TODO | Policy Guild | POLICY-CORE-09-002 | `/policy/preview` API (image digest → projected verdict delta). | Preview returns diff JSON; integration tests with mocked report; docs updated. |
|
||||||
|
| POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config with schema validation, trust table, and golden fixtures. | Scoring config documented; fixtures stored; validation CLI passes. |
|
||||||
|
| POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004 | Scoring/quiet engine – compute score, enforce VEX-only quiet rules, emit inputs and provenance. | Engine unit tests cover severity weighting; outputs include provenance data. |
|
||||||
|
| POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005 | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | Confidence decay tests pass; docs updated; preview endpoint displays banding. |
|
||||||
|
| POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. |
|
||||||
|
| POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. |
|
||||||
|
| POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user