up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -30,10 +30,10 @@
|
||||
| 3 | CONCELIER-POLICY-23-001 | DONE (2025-11-28) | Implemented migration `20251128_policy_lookup_indexes` with alias multikey, confidence, and severity indexes. Query patterns documented in migration XML docs. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Secondary indexes/materialized views (alias, provider severity, confidence) to keep policy lookups fast without cached verdicts; document query patterns. |
|
||||
| 4 | CONCELIER-POLICY-23-002 | DONE (2025-11-28) | Enhanced `AdvisoryLinksetUpdatedEvent` with `IdempotencyKey` (SHA256), `ConfidenceSummary` (tier/factors), and `TenantMetadata`. | Concelier Core Guild · Platform Events Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Ensure `advisory.linkset.updated` events carry idempotent IDs, confidence summaries, tenant metadata for safe policy replay. |
|
||||
| 5 | CONCELIER-RISK-66-001 | DONE (2025-11-28) | Created `VendorRiskSignal`, `VendorCvssScore`, `VendorKevStatus`, `VendorFixAvailability` models with provenance. Extractor parses OSV/NVD formats. | Concelier Core Guild · Risk Engine Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Surface vendor-provided CVSS/KEV/fix data exactly as published with provenance anchors via provider APIs. |
|
||||
| 6 | CONCELIER-RISK-66-002 | TODO | Upstream 66-001 DONE. Ready to emit fix-availability metadata. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Emit structured fix-availability metadata per observation/linkset (release version, advisory link, evidence timestamp) without guessing exploitability. |
|
||||
| 7 | CONCELIER-RISK-67-001 | TODO | Upstream 66-001 DONE. Ready to publish coverage/conflict metrics. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Publish per-source coverage/conflict metrics (counts, disagreements) so explainers cite which upstream statements exist; no weighting applied. |
|
||||
| 6 | CONCELIER-RISK-66-002 | DONE (2025-11-28) | Implemented `FixAvailabilityMetadata`, `FixRelease`, `FixAdvisoryLink` models + `IFixAvailabilityEmitter` interface + `FixAvailabilityEmitter` implementation in `src/Concelier/__Libraries/StellaOps.Concelier.Core/Risk/`. DI registration via `AddConcelierRiskServices()`. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Emit structured fix-availability metadata per observation/linkset (release version, advisory link, evidence timestamp) without guessing exploitability. |
|
||||
| 7 | CONCELIER-RISK-67-001 | DONE (2025-11-28) | Implemented `SourceCoverageMetrics`, `SourceContribution`, `SourceConflict` models + `ISourceCoverageMetricsPublisher` interface + `SourceCoverageMetricsPublisher` implementation + `InMemorySourceCoverageMetricsStore` in `src/Concelier/__Libraries/StellaOps.Concelier.Core/Risk/`. DI registration via `AddConcelierRiskServices()`. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Publish per-source coverage/conflict metrics (counts, disagreements) so explainers cite which upstream statements exist; no weighting applied. |
|
||||
| 8 | CONCELIER-RISK-68-001 | BLOCKED | Blocked on POLICY-RISK-68-001. | Concelier Core Guild · Policy Studio Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Wire advisory signal pickers into Policy Studio; validate selected fields are provenance-backed. |
|
||||
| 9 | CONCELIER-RISK-69-001 | BLOCKED | Blocked on 66-002. | Concelier Core Guild · Notifications Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Emit notifications on upstream advisory field changes (e.g., fix availability) with observation IDs + provenance; no severity inference. |
|
||||
| 9 | CONCELIER-RISK-69-001 | DONE (2025-11-28) | Implemented `AdvisoryFieldChangeNotification`, `AdvisoryFieldChange` models + `IAdvisoryFieldChangeEmitter` interface + `AdvisoryFieldChangeEmitter` implementation + `InMemoryAdvisoryFieldChangeNotificationPublisher` in `src/Concelier/__Libraries/StellaOps.Concelier.Core/Risk/`. Detects fix availability, KEV status, severity changes with provenance. | Concelier Core Guild · Notifications Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Emit notifications on upstream advisory field changes (e.g., fix availability) with observation IDs + provenance; no severity inference. |
|
||||
| 10 | CONCELIER-SIG-26-001 | BLOCKED | Blocked on SIGNALS-24-002. | Concelier Core Guild · Signals Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Expose upstream-provided affected symbol/function lists via APIs for reachability scoring; maintain provenance, no exploitability inference. |
|
||||
| 11 | CONCELIER-STORE-AOC-19-005-DEV | BLOCKED (2025-11-04) | Waiting on staging dataset hash + rollback rehearsal using prep doc | Concelier Storage Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo`) | Execute raw-linkset backfill/rollback plan so Mongo reflects Link-Not-Merge data; rehearse rollback (dev/staging). |
|
||||
| 12 | CONCELIER-TEN-48-001 | DONE (2025-11-28) | Created Tenancy module with `TenantScope`, `TenantCapabilities`, `TenantCapabilitiesResponse`, `ITenantCapabilitiesProvider`, and `TenantScopeNormalizer` per AUTH-TEN-47-001. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Enforce tenant scoping through normalization/linking; expose capability endpoint advertising `merge=false`; ensure events include tenant IDs. |
|
||||
@@ -42,6 +42,9 @@
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-11-28 | Completed CONCELIER-RISK-69-001: implemented `AdvisoryFieldChangeNotification`, `AdvisoryFieldChange`, `AdvisoryFieldChangeProvenance` models + `IAdvisoryFieldChangeEmitter` interface + `AdvisoryFieldChangeEmitter` implementation + `IAdvisoryFieldChangeNotificationPublisher` interface + `InMemoryAdvisoryFieldChangeNotificationPublisher`. Detects changes in fix availability, KEV status, severity, CVSS score, and observation status with full provenance. DI registration via `AddConcelierRiskServices()`. Sprint 0115 RISK tasks now complete (66-001, 66-002, 67-001, 69-001 DONE; 68-001 BLOCKED on POLICY-RISK-68-001). | Implementer |
|
||||
| 2025-11-28 | Completed CONCELIER-RISK-66-002: implemented `FixAvailabilityMetadata`, `FixRelease`, `FixAdvisoryLink` models with provenance anchors + `IFixAvailabilityEmitter` interface + `FixAvailabilityEmitter` implementation for emitting structured fix-availability metadata per observation/linkset. DI registration via `AddConcelierRiskServices()`. Unblocked CONCELIER-RISK-69-001. | Implementer |
|
||||
| 2025-11-28 | Completed CONCELIER-RISK-67-001: implemented `SourceCoverageMetrics`, `SourceContribution`, `SourceCoverageDetail`, `SourceAgreementSummary`, `SourceConflict` models + `ISourceCoverageMetricsPublisher` interface + `SourceCoverageMetricsPublisher` implementation + `InMemorySourceCoverageMetricsStore` for per-source coverage/conflict metrics. No weighting applied; fact-only counts and disagreements. DI registration via `AddConcelierRiskServices()`. | Implementer |
|
||||
| 2025-11-28 | Completed CONCELIER-TEN-48-001: created Tenancy module with `TenantScope`, `TenantCapabilities`, `TenantCapabilitiesResponse`, `ITenantCapabilitiesProvider`, `LinkNotMergeTenantCapabilitiesProvider`, and `TenantScopeNormalizer`. Implements AUTH-TEN-47-001 contract with capabilities endpoint response and tenant ID normalization. Build green. | Implementer |
|
||||
| 2025-11-28 | Completed CONCELIER-RISK-66-001: created Risk module with `VendorRiskSignal`, `VendorCvssScore`, `VendorKevStatus`, `VendorFixAvailability` models + `IVendorRiskSignalProvider` interface + `VendorRiskSignalExtractor` for OSV/NVD parsing. All with provenance anchors. Build green. Tasks 6 and 7 now TODO. | Implementer |
|
||||
| 2025-11-28 | Unblocked CONCELIER-RISK-66-001 and CONCELIER-TEN-48-001 after POLICY chain completion. Tasks 5 and 12 moved to TODO. | Implementer |
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | CVSS-MODEL-190-001 | TODO | None; foundational. | Policy Guild · Signals Guild (`src/Policy/StellaOps.Policy.Scoring`) | Design and implement CVSS v4.0 data model: `CvssScoreReceipt`, `BaseMetrics`, `ThreatMetrics`, `EnvironmentalMetrics`, `SupplementalMetrics`, `EvidenceItem`, `CvssPolicy`, `ReceiptHistoryEntry`. Include EF Core mappings and MongoDB schema. |
|
||||
| 2 | CVSS-ENGINE-190-002 | TODO | Depends on 190-001 for types. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Engine`) | Implement `CvssV4Engine` with: `ParseVector()`, `ComputeBaseScore()`, `ComputeThreatAdjustedScore()`, `ComputeEnvironmentalAdjustedScore()`, `BuildVector()`. Follow FIRST spec v4.0 exactly for math/rounding. |
|
||||
| 3 | CVSS-TESTS-190-003 | TODO | Depends on 190-002. | Policy Guild · QA Guild (`src/Policy/__Tests/StellaOps.Policy.Scoring.Tests`) | Unit tests for CVSS v4.0 engine using official FIRST sample vectors; edge cases for missing threat/env; determinism tests (same input → same output). |
|
||||
| 1 | CVSS-MODEL-190-001 | DONE (2025-11-28) | None; foundational. | Policy Guild · Signals Guild (`src/Policy/StellaOps.Policy.Scoring`) | Design and implement CVSS v4.0 data model: `CvssScoreReceipt`, `BaseMetrics`, `ThreatMetrics`, `EnvironmentalMetrics`, `SupplementalMetrics`, `EvidenceItem`, `CvssPolicy`, `ReceiptHistoryEntry`. Include EF Core mappings and MongoDB schema. Evidence: Created `StellaOps.Policy.Scoring` project with `CvssMetrics.cs` (all CVSS v4.0 metric enums/records), `CvssScoreReceipt.cs` (receipt model with scores, evidence, history), `CvssPolicy.cs` (policy configuration), JSON schemas `cvss-policy-schema@1.json` and `cvss-receipt-schema@1.json`, and `AGENTS.md`. |
|
||||
| 2 | CVSS-ENGINE-190-002 | DONE (2025-11-28) | Depends on 190-001 for types. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Engine`) | Implement `CvssV4Engine` with: `ParseVector()`, `ComputeBaseScore()`, `ComputeThreatAdjustedScore()`, `ComputeEnvironmentalAdjustedScore()`, `BuildVector()`. Follow FIRST spec v4.0 exactly for math/rounding. Evidence: `ICvssV4Engine.cs` interface, `CvssV4Engine.cs` implementation with MacroVector computation (EQ1-EQ6), threat/environmental modifiers, vector string building/parsing, `MacroVectorLookup.cs` with score tables. |
|
||||
| 3 | CVSS-TESTS-190-003 | DONE (2025-11-28) | Depends on 190-002. | Policy Guild · QA Guild (`src/Policy/__Tests/StellaOps.Policy.Scoring.Tests`) | Unit tests for CVSS v4.0 engine using official FIRST sample vectors; edge cases for missing threat/env; determinism tests (same input → same output). Evidence: Created `StellaOps.Policy.Scoring.Tests` project with `CvssV4EngineTests.cs` containing tests for base/threat/environmental/full scores, vector string building/parsing, severity thresholds, determinism, and FIRST sample vectors. |
|
||||
| 4 | CVSS-POLICY-190-004 | TODO | Depends on 190-002. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Policies`) | Implement `CvssPolicy` loader and validator: JSON schema for policy files, policy versioning, hash computation for determinism tracking. |
|
||||
| 5 | CVSS-RECEIPT-190-005 | TODO | Depends on 190-002, 190-004. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Receipts`) | Implement `ReceiptBuilder` service: `CreateReceipt(vulnId, input, policyId, userId)` that computes scores, builds vector, hashes inputs, and persists receipt with evidence links. |
|
||||
| 6 | CVSS-DSSE-190-006 | TODO | Depends on 190-005; uses Attestor primitives. | Policy Guild · Attestor Guild (`src/Policy/StellaOps.Policy.Scoring`, `src/Attestor/StellaOps.Attestor.Envelope`) | Attach DSSE attestations to score receipts: create `stella.ops/cvssReceipt@v1` predicate type, sign receipts, store envelope references. |
|
||||
@@ -72,3 +72,7 @@
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-11-27 | Sprint created from product advisory `25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md`; 12 tasks defined across 4 waves. | Product Mgmt |
|
||||
| 2025-11-28 | CVSS-MODEL-190-001 DONE: Created `StellaOps.Policy.Scoring` project with complete CVSS v4.0 data model per FIRST spec. Includes `CvssMetrics.cs` (Base/Threat/Environmental/Supplemental metrics with all enum values), `CvssScoreReceipt.cs` (receipt with scores, evidence, history, DSSE refs), `CvssPolicy.cs` (policy configuration with overrides, thresholds, attestation requirements), JSON schemas for validation, and `AGENTS.md`. | Implementer |
|
||||
| 2025-11-28 | Started CVSS-ENGINE-190-002: Implementing scoring engine with MacroVector lookup tables per FIRST CVSS v4.0 specification. | Implementer |
|
||||
| 2025-11-28 | CVSS-ENGINE-190-002 DONE: Implemented `ICvssV4Engine` interface and `CvssV4Engine` class with full scoring logic. EQ1-EQ6 equivalence class computation, MacroVector lookup table with score interpolation, threat/environmental score modifiers, round-up per FIRST spec, vector string building/parsing with regex. Started CVSS-TESTS-190-003. | Implementer |
|
||||
| 2025-11-28 | CVSS-TESTS-190-003 DONE: Created test project `StellaOps.Policy.Scoring.Tests` with `CvssV4EngineTests.cs`. Comprehensive test suite covers: base/threat/environmental/full score computation, vector string building and parsing, severity thresholds (default and custom), determinism verification, FIRST sample vectors, roundtrip preservation. Wave 1 (Foundation) complete - all 4 tasks DONE. | Implementer |
|
||||
|
||||
@@ -11,16 +11,17 @@ TELEMETRY-OBS-50-001 | DONE (2025-11-19) | `StellaOps.Telemetry.Core` bootstrap
|
||||
TELEMETRY-OBS-50-002 | DONE (2025-11-27) | Implement context propagation middleware/adapters for HTTP, gRPC, background jobs, and CLI invocations, carrying `trace_id`, `tenant_id`, `actor`, and imposed-rule metadata. Provide test harness covering async resume scenarios. Dependencies: TELEMETRY-OBS-50-001. | Telemetry Core Guild (src/Telemetry/StellaOps.Telemetry.Core)
|
||||
TELEMETRY-OBS-51-001 | DONE (2025-11-27) | Ship metrics helpers for golden signals (histograms, counters, gauges) with exemplar support and cardinality guards. Provide Roslyn analyzer preventing unsanitised labels. Dependencies: TELEMETRY-OBS-50-002. Evidence: `GoldenSignalMetrics.cs` + `StellaOps.Telemetry.Analyzers` project with `MetricLabelAnalyzer` (TELEM001/002/003 diagnostics). | Telemetry Core Guild, Observability Guild (src/Telemetry/StellaOps.Telemetry.Core)
|
||||
TELEMETRY-OBS-51-002 | DONE (2025-11-27) | Implement redaction/scrubbing filters for secrets/PII enforced at logger sink, configurable per-tenant with TTL, including audit of overrides. Add determinism tests verifying stable field order and timestamp normalization. Dependencies: TELEMETRY-OBS-51-001. Evidence: `LogRedactor`, `LogRedactionOptions`, `RedactingLogProcessor`, `DeterministicLogFormatter` + test suites. | Telemetry Core Guild, Security Guild (src/Telemetry/StellaOps.Telemetry.Core)
|
||||
TELEMETRY-OBS-55-001 | TODO | Provide incident mode toggle API that adjusts sampling, enables extended retention tags, and records activation trail for services. Ensure toggle honored by all hosting templates and integrates with Config/FeatureFlag providers. Dependencies: TELEMETRY-OBS-51-002. | Telemetry Core Guild (src/Telemetry/StellaOps.Telemetry.Core)
|
||||
TELEMETRY-OBS-56-001 | TODO | Add sealed-mode telemetry helpers (drift metrics, seal/unseal spans, offline exporters) and ensure hosts can disable external exporters when sealed. Dependencies: TELEMETRY-OBS-55-001. | Telemetry Core Guild (src/Telemetry/StellaOps.Telemetry.Core)
|
||||
TELEMETRY-OBS-55-001 | DONE (2025-11-28) | Provide incident mode toggle API that adjusts sampling, enables extended retention tags, and records activation trail for services. Ensure toggle honored by all hosting templates and integrates with Config/FeatureFlag providers. Dependencies: TELEMETRY-OBS-51-002. Evidence: `IIncidentModeService`/`IncidentModeService` with full state management, TTL handling, events, persistence; `IncidentModeOptions` for configuration; `AddIncidentMode()` DI extension; comprehensive test suite in `IncidentModeServiceTests`. | Telemetry Core Guild (src/Telemetry/StellaOps.Telemetry.Core)
|
||||
TELEMETRY-OBS-56-001 | DONE (2025-11-28) | Add sealed-mode telemetry helpers (drift metrics, seal/unseal spans, offline exporters) and ensure hosts can disable external exporters when sealed. Dependencies: TELEMETRY-OBS-55-001. Evidence: `ISealedModeTelemetryService`/`SealedModeTelemetryService` with metrics counters (`sealEventsCounter`, `unsealEventsCounter`, `driftEventsCounter`, `blockedExportsCounter`), `SealedModeFileExporter` for offline export, `TelemetryExporterGuard` for blocking external exporters; `AddSealedModeTelemetry()` DI extension; test suite in `SealedModeTelemetryServiceTests`. | Telemetry Core Guild (src/Telemetry/StellaOps.Telemetry.Core)
|
||||
|
||||
## Status notes (2025-11-19 UTC)
|
||||
## Status notes (2025-11-28 UTC)
|
||||
|
||||
- **TELEMETRY-OBS-50-001** – DONE. Library merged with deterministic bootstrap helpers; sample host + test harness published in `docs/observability/telemetry-bootstrap.md`.
|
||||
- **TELEMETRY-OBS-50-002** – Awaiting adoption of published bootstrap before wiring propagation adapters; design still covers HTTP/gRPC/job/CLI interceptors plus tenant/actor propagation tests.
|
||||
- **TELEMETRY-OBS-51-001** – DONE. Golden signal metrics (`GoldenSignalMetrics.cs`) with exemplar support and cardinality guards already existed. Added Roslyn analyzer project (`StellaOps.Telemetry.Analyzers`) with `MetricLabelAnalyzer` enforcing TELEM001 (high-cardinality patterns), TELEM002 (invalid key format), TELEM003 (dynamic labels).
|
||||
- **TELEMETRY-OBS-51-002** – DONE. Implemented `ILogRedactor`/`LogRedactor` with pattern-based and field-name redaction. Per-tenant overrides with TTL and audit logging. `DeterministicLogFormatter` ensures stable field ordering and UTC timestamp normalization.
|
||||
- **TELEMETRY-OBS-55-001/56-001** – Incident/sealed-mode APIs remain blocked on CLI toggle contract (CLI-OBS-12-001) and Notify incident payload spec (NOTIFY-OBS-55-001); coordination with Notifier team continues.
|
||||
- **TELEMETRY-OBS-50-002** – DONE. Context propagation middleware for HTTP, gRPC, CLI, and background jobs; includes async resume test harness.
|
||||
- **TELEMETRY-OBS-51-001** – DONE. Golden signal metrics (`GoldenSignalMetrics.cs`) with exemplar support and cardinality guards. Roslyn analyzer project (`StellaOps.Telemetry.Analyzers`) with `MetricLabelAnalyzer` enforcing TELEM001/002/003 diagnostics.
|
||||
- **TELEMETRY-OBS-51-002** – DONE. `ILogRedactor`/`LogRedactor` with pattern-based and field-name redaction. Per-tenant overrides with TTL and audit logging. `DeterministicLogFormatter` ensures stable field ordering and UTC timestamp normalization.
|
||||
- **TELEMETRY-OBS-55-001** – DONE. Incident mode toggle API implemented with `IIncidentModeService`/`IncidentModeService` providing: sampling adjustment, extended retention tags, activation trail recording, state persistence, events, TTL management with extension support, CLI/API/config activation sources. DI registration via `AddIncidentMode()`. Full test suite.
|
||||
- **TELEMETRY-OBS-56-001** – DONE. Sealed-mode telemetry helpers implemented with `ISealedModeTelemetryService`/`SealedModeTelemetryService` providing: drift metrics counters, seal/unseal spans, offline file exporter (`SealedModeFileExporter`), external exporter blocking via `TelemetryExporterGuard`. DI registration via `AddSealedModeTelemetry()`. Full test suite.
|
||||
|
||||
## Milestones & dependencies
|
||||
|
||||
@@ -40,3 +41,5 @@ TELEMETRY-OBS-56-001 | TODO | Add sealed-mode telemetry helpers (drift metrics,
|
||||
| 2025-11-27 | Marked TELEMETRY-OBS-50-002 DONE; added gRPC interceptors, CLI context, and async resume test harness. | Implementer |
|
||||
| 2025-11-27 | Marked TELEMETRY-OBS-51-001 DONE; created `StellaOps.Telemetry.Analyzers` project with `MetricLabelAnalyzer` (TELEM001/002/003) and test suite. | Implementer |
|
||||
| 2025-11-27 | Marked TELEMETRY-OBS-51-002 DONE; implemented `LogRedactor`, `LogRedactionOptions`, `RedactingLogProcessor`, `DeterministicLogFormatter` with comprehensive test suites. | Implementer |
|
||||
| 2025-11-28 | Marked TELEMETRY-OBS-55-001 DONE; verified existing implementation of `IIncidentModeService`/`IncidentModeService` with state management, TTL handling, events, persistence, and comprehensive test suite. | Implementer |
|
||||
| 2025-11-28 | Marked TELEMETRY-OBS-56-001 DONE; verified existing implementation of `ISealedModeTelemetryService`/`SealedModeTelemetryService` with metrics, spans, offline exporter, and exporter guard. Sprint 174 Telemetry complete. | Implementer |
|
||||
|
||||
@@ -7,20 +7,20 @@ Depends on: Sprint 180.A - Cli.I
|
||||
Summary: Experience & SDKs focus on Cli (phase II).
|
||||
Task ID | State | Task description | Owners (Source)
|
||||
--- | --- | --- | ---
|
||||
CLI-CORE-41-001 | TODO | Implement CLI core features: config precedence, profiles/contexts, auth flows, output renderer (json/yaml/table), error mapping, global flags, telemetry opt-in. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-EXC-25-001 | TODO | Implement `stella exceptions list | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-EXC-25-002 | TODO | Extend `stella policy simulate` with `--with-exception`/`--without-exception` flags to preview exception impact. Dependencies: CLI-EXC-25-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-CORE-41-001 | DONE (2025-11-28) | Implemented CLI core features: `OutputRenderer` (json/yaml/table), `CliProfile`/`CliProfileManager` (profiles/contexts), `CliError`/`CliErrorCodes` (error mapping), `GlobalOptions` (global flags with --profile, --output, --verbose, --quiet, --no-color, --dry-run). Config precedence already exists in `CliBootstrapper`. Auth flows already exist via `StellaOps.Auth.Client`. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-EXC-25-001 | DONE (2025-11-28) | Implemented `stella exceptions list/show/create/promote/revoke/import/export` commands for exception governance. Created `ExceptionModels.cs` with full models for exception instances, scopes, effects, evidence refs, lifecycle states (draft/staged/active/expired/revoked), and request/response types. Created `IExceptionClient.cs` interface and `ExceptionClient.cs` HTTP client with token caching for all CRUD operations plus import/export. Added command handlers with JSON/table output, status-colored rendering, verbose mode with evidence/approval details, and `ERR_EXC_*` error codes (exit code 16). | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-EXC-25-002 | DONE (2025-11-28) | Extended `stella policy simulate` with `--with-exception`/`--without-exception` flags to preview exception impact. Added repeatable options for exception IDs, validation to prevent overlapping IDs in both lists, verbose logging of exception preview mode, and OpenTelemetry tracing of exception counts. Updated `PolicySimulationInput` record with optional `WithExceptions`/`WithoutExceptions` fields. Dependencies: CLI-EXC-25-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-EXPORT-35-001 | BLOCKED (2025-10-29) | Implement `stella export profiles | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-EXPORT-36-001 | TODO | Add distribution commands (`stella export distribute`, `run download --resume` enhancements) and improved status polling with progress bars. Dependencies: CLI-EXPORT-35-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-EXPORT-37-001 | TODO | Provide scheduling (`stella export schedule`), retention, and `export verify` commands performing signature/hash validation. Dependencies: CLI-EXPORT-36-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-FORENSICS-53-001 | TODO | Implement `stella forensic snapshot create --case` and `snapshot list/show` commands invoking evidence locker APIs, surfacing manifest digests, and storing local cache metadata. | DevEx/CLI Guild, Evidence Locker Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-FORENSICS-54-001 | TODO | Provide `stella forensic verify <bundle>` command validating checksums, DSSE signatures, and timeline chain-of-custody. Support JSON/pretty output and exit codes for CI. Dependencies: CLI-FORENSICS-53-001. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-FORENSICS-54-002 | TODO | Implement `stella forensic attest show <artifact>` listing attestation details (signer, timestamp, subjects) and verifying signatures. Dependencies: CLI-FORENSICS-54-001. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PROMO-70-001 | TODO | Add `stella promotion assemble` command that resolves image digests, hashes SBOM/VEX artifacts, fetches Rekor proofs from Attestor, and emits the `stella.ops/promotion@v1` JSON payload (see `docs/release/promotion-attestations.md`). | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-DETER-70-003 | TODO | Provide `stella detscore run` that executes the determinism harness locally (fixed clock, seeded RNG, canonical hashes) and writes `determinism.json`, supporting CI/non-zero threshold exit codes (`docs/modules/scanner/determinism-score.md`). | DevEx/CLI Guild, Scanner Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-LNM-22-001 | TODO | Implement `stella advisory obs get/linkset show/export` commands with JSON/OSV output, pagination, and conflict display; ensure `ERR_AGG_*` mapping. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-LNM-22-002 | TODO | Implement `stella vex obs get/linkset show` commands with product filters, status filters, and JSON output for CI usage. Dependencies: CLI-LNM-22-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-FORENSICS-53-001 | DONE (2025-11-28) | Implemented `stella forensic snapshot create --case` and `snapshot list/show` commands invoking evidence locker APIs. Created `ForensicSnapshotModels.cs` with full document/manifest/artifact models, `IForensicSnapshotClient.cs` interface, `ForensicSnapshotClient.cs` HTTP client with token caching, and command handlers in `CommandHandlers.cs` with JSON/table output. | DevEx/CLI Guild, Evidence Locker Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-FORENSICS-54-001 | DONE (2025-11-28) | Implemented `stella forensic verify <bundle>` command validating checksums, DSSE signatures, and timeline chain-of-custody. Created `ForensicVerificationModels.cs` with verification result models, `IForensicVerifier.cs` interface, `ForensicVerifier.cs` with SHA256/384/512 checksum verification, RSA-PSS signature verification, and chain-of-custody timeline validation. Added `ERR_FORENSIC_*` error codes (exit code 12), JSON/pretty output, and verbose mode with detailed tables. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-FORENSICS-54-002 | DONE (2025-11-28) | Implemented `stella forensic attest show <artifact>` listing attestation details (signer, timestamp, subjects) and verifying signatures. Created `AttestationModels.cs` with DSSE/in-toto models, `IAttestationReader.cs` interface, `AttestationReader.cs` with PAE encoding, RSA-PSS verification, predicate parsing (SLSA/VEX), and rich console output with subject/signature tables. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PROMO-70-001 | DONE (2025-11-28) | Implemented `stella promotion assemble` command that resolves image digests (via crane/cosign), hashes SBOM/VEX artifacts with format detection (CycloneDX/SPDX, OpenVEX/CSAF), and emits the `stella.ops/promotion@v1` JSON payload. Created `PromotionModels.cs` with full predicate/subject/material/metadata models, `IPromotionAssembler.cs` interface, `PromotionAssembler.cs` with image digest resolution, SHA256 file hashing, SBOM/VEX format detection, and JSON output. Command supports `--image`, `--sbom`, `--vex`, `--from/--to` environment, `--actor`, `--ticket`, `--notes`, `--skip-rekor`, and `--output` options. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-DETER-70-003 | DONE (2025-11-28) | Implemented `stella detscore run` command that executes the determinism harness locally with fixed clock, seeded RNG, and canonical hashes. Created `DeterminismModels.cs` with manifest/request/result models per SCAN-DETER-186-010 schema, `IDeterminismHarness.cs` interface, `DeterminismHarness.cs` with Docker container execution, SHA256 artifact hashing, score calculation, and threshold verification. Command supports `--image`, `--scanner`, `--policy-bundle`, `--feeds-bundle`, `--runs`, `--fixed-clock`, `--rng-seed`, `--max-concurrency`, `--memory`, `--cpuset`, `--platform`, `--image-threshold`, `--overall-threshold`, `--output-dir`, `--release`, and `--json` options. Added `ERR_DETER_*` error codes (exit code 13). | DevEx/CLI Guild, Scanner Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-LNM-22-001 | DONE (2025-11-28) | Implemented `stella advisory obs get/linkset show/export` commands with JSON/OSV output, pagination, conflict display, and `ERR_AGG_*` error code mapping. Added `AdvisoryLinksetModels.cs` with OSV format support, extended `IConcelierObservationsClient` with `GetLinksetAsync`/`GetObservationByIdAsync`, and added command handlers for all three subcommands. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-LNM-22-002 | DONE (2025-11-28) | Implemented `stella vex obs get/linkset show` commands with product/status/provider filters, pagination, and JSON output for CI usage. Created `VexObservationModels.cs` with query/response/linkset models, `IVexObservationsClient.cs` interface, `VexObservationsClient.cs` HTTP client with VexRead scope, and command handlers with rich table output, conflict detection, and aggregate summaries. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-NOTIFY-38-001 | BLOCKED (2025-10-29) | Implement `stella notify rules | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-NOTIFY-39-001 | BLOCKED (2025-10-29) | Add simulation (`stella notify simulate`) and digest commands with diff output and schedule triggering, including dry-run mode. Dependencies: CLI-NOTIFY-38-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-NOTIFY-40-001 | TODO | Provide ack token redemption workflow, escalation management, localization previews, and channel health checks. Dependencies: CLI-NOTIFY-39-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-OBS-50-001 | TODO | Ensure CLI HTTP client propagates `traceparent` headers for all commands, prints correlation IDs on failure, and records trace IDs in verbose logs (scrubbed). | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-OBS-50-001 | DONE (2025-11-28) | Implemented `TraceparentHttpMessageHandler` that propagates W3C Trace Context headers for all HTTP requests, logs correlation IDs on failure, records trace IDs in verbose logs (scrubbed), and includes `AddTraceparentPropagation()` extension method for IHttpClientBuilder. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
|
||||
@@ -7,22 +7,22 @@ Depends on: Sprint 180.A - Cli.II
|
||||
Summary: Experience & SDKs focus on Cli (phase III).
|
||||
Task ID | State | Task description | Owners (Source)
|
||||
--- | --- | --- | ---
|
||||
CLI-OBS-51-001 | TODO | Implement `stella obs top` command streaming service health metrics, SLO status, and burn-rate alerts with TUI view and JSON output. Dependencies: CLI-OBS-50-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-OBS-52-001 | TODO | Add `stella obs trace <trace_id>` and `stella obs logs --from/--to` commands that correlate timeline events, logs, and evidence links with pagination + guardrails. Dependencies: CLI-OBS-51-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-OBS-55-001 | TODO | Add `stella obs incident-mode enable. Dependencies: CLI-OBS-52-001. | DevEx/CLI Guild, DevOps Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-ORCH-32-001 | TODO | Implement `stella orch sources | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-ORCH-33-001 | TODO | Add action verbs (`sources test. Dependencies: CLI-ORCH-32-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-ORCH-34-001 | TODO | Provide backfill wizard (`--from/--to --dry-run`), quota management (`quotas get. Dependencies: CLI-ORCH-33-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PACKS-42-001 | TODO | Implement Task Pack commands (`pack plan/run/push/pull/verify`) with schema validation, expression sandbox, plan/simulate engine, remote execution. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PROMO-70-002 | TODO | Implement `stella promotion attest` / `promotion verify` commands that sign the promotion payload via Signer, retrieve DSSE bundles from Attestor, and perform offline verification against trusted checkpoints (`docs/release/promotion-attestations.md`). Dependencies: CLI-PROMO-70-001. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-DETER-70-004 | TODO | Add `stella detscore report` to summarise published `determinism.json` files (overall score, per-image matrix) and integrate with release notes/air-gap kits (`docs/modules/scanner/determinism-score.md`). Dependencies: CLI-DETER-70-003. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PACKS-43-001 | TODO | Deliver advanced pack features (approvals pause/resume, secret injection, localization, man pages, offline cache). Dependencies: CLI-PACKS-42-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PARITY-41-001 | TODO | Deliver parity command groups (`policy`, `sbom`, `vuln`, `vex`, `advisory`, `export`, `orchestrator`) with `--explain`, deterministic outputs, and parity matrix entries. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PARITY-41-002 | TODO | Implement `notify`, `aoc`, `auth` command groups, idempotency keys, shell completions, config docs, and parity matrix export tooling. Dependencies: CLI-PARITY-41-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SBOM-60-001 | TODO | Ship `stella sbomer layer`/`compose` verbs that capture per-layer fragments, run canonicalization, verify fragment DSSE, and emit `_composition.json` + Merkle diagnostics (ref `docs/modules/scanner/deterministic-sbom-compose.md`). Dependencies: CLI-PARITY-41-001, SCANNER-SURFACE-04. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SBOM-60-002 | TODO | Add `stella sbomer drift --explain` + `verify` commands that rerun composition locally, highlight which arrays/keys broke determinism, and integrate with Offline Kit bundles. Dependencies: CLI-SBOM-60-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-20-001 | TODO | Add `stella policy new | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-23-004 | TODO | Add `stella policy lint` command validating SPL files with compiler diagnostics; support JSON output. Dependencies: CLI-POLICY-20-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-OBS-51-001 | DONE (2025-11-28) | Implemented `stella obs top` command streaming service health metrics, SLO status, and burn-rate alerts. Features: (1) TUI table view with color-coded health status, availability, error budget, P95 latency, burn rate; (2) JSON and NDJSON output modes for CI; (3) Streaming mode with `--refresh` interval for live monitoring; (4) Active alerts display with severity and age; (5) Queue health details in verbose mode; (6) Offline mode guard per CLI guide. Created `ObservabilityModels.cs` with `ServiceHealthStatus`, `PlatformHealthSummary`, `BurnRateInfo`, `LatencyInfo`, `QueueHealth`, `ActiveAlert` models. Added `IObservabilityClient` interface and `ObservabilityClient` implementation. Extended `CliErrorCodes` with ERR_OBS_* codes (exit 14). Registered client in `Program.cs`. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-OBS-52-001 | DONE (2025-11-28) | Implemented `stella obs trace <trace_id>` and `stella obs logs --from/--to` commands. Features: (1) Trace command fetches distributed trace by ID with spans table, duration, status, evidence links (SBOM/VEX/attestation); (2) Logs command fetches logs for time window with service/level filters, full-text query, deterministic pagination with page-token; (3) Both support JSON/NDJSON/table output; (4) Offline mode guard with exit code 5; (5) 24-hour guardrail warning on large time windows; (6) Trace ID echoed on stderr in verbose mode for scripting. Extended `ObservabilityModels.cs` with `DistributedTrace`, `TraceSpan`, `SpanLog`, `EvidenceLink`, `LogEntry`, request/result types. Extended `IObservabilityClient` and `ObservabilityClient` with `GetTraceAsync`/`GetLogsAsync`. Added handlers to `CommandHandlers.cs`. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-OBS-55-001 | DONE (2025-11-28) | Implemented `stella obs incident-mode` command group with enable/disable/status subcommands. Features: (1) Enable subcommand activates incident mode with configurable TTL (default 30min) and retention extension (default 60 days); (2) Disable subcommand deactivates incident mode with optional reason; (3) Status subcommand displays current incident mode state with expiry countdown; (4) All subcommands support JSON output for scripting; (5) Offline mode guard per CLI guide; (6) Audit event ID returned for compliance tracking; (7) Rich console output with Spectre.Console panels showing actor, source, timestamps. Extended `ObservabilityModels.cs` with `IncidentModeState`, `IncidentModeEnableRequest`, `IncidentModeDisableRequest`, `IncidentModeResult` models. Extended `IObservabilityClient` and `ObservabilityClient` with `GetIncidentModeStatusAsync`/`EnableIncidentModeAsync`/`DisableIncidentModeAsync`. Added handlers to `CommandHandlers.cs`. | DevEx/CLI Guild, DevOps Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-ORCH-32-001 | DONE (2025-11-28) | Implemented `stella orch sources list/show` commands for orchestrator source management. Created `OrchestratorModels.cs` with full models for sources (status, schedule, rate limits, metrics, last run), `IOrchestratorClient.cs` interface, `OrchestratorClient.cs` HTTP client with OrchRead scope. Added command handlers with JSON/table output, status-colored rendering, verbose mode with schedule/rate-limit/metrics/last-run details, and `ERR_ORCH_*` error codes (exit code 17). | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-ORCH-33-001 | DONE (2025-11-28) | Implemented `stella orch sources test/pause/resume` action verbs for orchestrator source management. Features: (1) `sources test` validates connectivity to a source with configurable timeout, returns connectivity status, response time, and diagnostics; (2) `sources pause` temporarily stops scheduled runs with optional reason and duration, returns operation result with audit event ID; (3) `sources resume` reactivates a paused source with optional reason, returns operation result with new status. All commands support JSON output for scripting, offline mode guard, and verbose mode for detailed diagnostics. Extended `OrchestratorModels.cs` with `SourceTestRequest`, `SourceTestResult`, `SourcePauseRequest`, `SourceResumeRequest`, `SourceOperationResult` models. Extended `IOrchestratorClient` and `OrchestratorClient` with `TestSourceAsync`/`PauseSourceAsync`/`ResumeSourceAsync`. Added handlers to `CommandHandlers.cs`. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-ORCH-34-001 | DONE (2025-11-28) | Implemented `stella orch backfill` and `stella orch quotas` command groups. Backfill features: (1) `backfill start` with --from/--to date range, --dry-run preview mode, --priority/--concurrency/--batch-size tuning, --resume checkpoint support, --filter expression, --force overwrite; (2) `backfill status` displays progress, processed/failed/skipped counts, estimated and actual duration; (3) `backfill list` with source/status filters and pagination; (4) `backfill cancel` with reason for audit log. Quota features: (1) `quotas get` displays usage vs limits with warning/exceeded status, formatted byte values for storage types; (2) `quotas set` configures limits with period (hourly/daily/weekly/monthly) and warning threshold; (3) `quotas reset` clears usage counter with audit reason. All commands support JSON output, verbose mode, and offline mode guard. Extended `OrchestratorModels.cs` with `BackfillRequest/Result`, `BackfillListRequest/Response`, `BackfillCancelRequest`, `OrchestratorQuota`, `QuotaGetRequest/Response`, `QuotaSetRequest`, `QuotaResetRequest`, `QuotaOperationResult` models. Extended `IOrchestratorClient` and `OrchestratorClient` with backfill and quota operations. Added handlers to `CommandHandlers.cs` with Spectre.Console rich output for backfill panels and quota tables. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PACKS-42-001 | DONE (2025-11-28) | Implemented `stella pack` command group with plan/run/push/pull/verify subcommands. Features: (1) `pack plan` validates pack inputs, generates execution graph with step dependencies, reports approval gates and estimated duration; (2) `pack run` executes pack with --wait option for synchronous completion, --label for metadata, --plan-id to reuse existing plans; (3) `pack push` uploads pack to registry with optional signing via --sign/--key-id, --force to overwrite; (4) `pack pull` downloads pack from registry with signature verification by default; (5) `pack verify` validates pack signature, digest, schema, Rekor transparency, and certificate expiry. Created `PackModels.cs` with `TaskPackInfo`, `PackPlanRequest/Result`, `PackRunRequest/Result/Status`, `PackPushRequest/Result`, `PackPullRequest/Result`, `PackVerifyRequest/Result`, `PackStepStatus`, `PackArtifact`, `PackValidationError` models. Added `IPackClient` interface and `PackClient` implementation with HTTP client for registry/runner APIs. Extended `CliErrorCodes` with ERR_PACK_* codes (exit 15). Registered client in `Program.cs`. Added handlers to `CommandHandlers.cs` with Spectre.Console rich output for plan tables, run status, and verify panels. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PROMO-70-002 | DONE (2025-11-28) | Implemented `stella promotion attest` and `promotion verify` commands. Attest signs promotion predicates via cosign/Signer API, produces DSSE bundles, and uploads to Rekor. Verify performs offline verification of DSSE signatures (ECDSA/RSA-PKCS1), material digest comparison (SBOM/VEX), and Rekor inclusion proof validation against trusted checkpoints. Extended `PromotionModels.cs` with request/result types for attest/verify, added DsseEnvelope/DsseSignature models, implemented `AttestAsync`/`VerifyAsync` in `PromotionAssembler.cs` with PAE encoding, certificate chain verification, and Merkle inclusion proof validation. | DevEx/CLI Guild, Provenance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-DETER-70-004 | DONE (2025-11-28) | Implemented `stella detscore report` command to summarise published `determinism.json` files. Features: (1) Aggregates multiple manifests into unified report with overall/per-image score matrix, (2) Supports markdown/JSON/CSV output formats, (3) Computes summary statistics (average, min/max scores, pass/fail counts), (4) Tracks non-deterministic artifacts across releases, (5) Integrates with release notes and air-gap kits via `--output` flag. Extended `DeterminismModels.cs` with `DeterminismReportRequest`, `DeterminismReport`, `DeterminismReportSummary`, `DeterminismReleaseEntry`, `DeterminismImageMatrixEntry`, and `DeterminismReportResult`. Added `GenerateReportAsync` to `IDeterminismHarness` interface and implemented in `DeterminismHarness.cs` with markdown table generation, CSV export, and JSON serialization. Added `detscore report` command to `CommandFactory.cs` and `HandleDetscoreReportAsync` handler to `CommandHandlers.cs` with Spectre.Console rich output. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PACKS-43-001 | DONE (2025-11-28) | Implemented advanced pack features for `stella pack` command group. Features: (1) `pack runs list` lists pack runs with status/actor/pack-id filters, pagination, and deterministic ordering; (2) `pack runs show` displays detailed run status with step progress, artifacts, and timing; (3) `pack runs cancel` cancels running pack with reason for audit; (4) `pack runs pause` pauses run at approval gate with optional step targeting; (5) `pack runs resume` resumes paused run with approve/reject decision and optional comment; (6) `pack runs logs` retrieves run logs with step/level filters, --tail for last N lines, --since timestamp; (7) `pack secrets inject` injects secrets from vault/aws-ssm/azure-keyvault/k8s-secret providers with env-var or file path targeting per step; (8) `pack cache list` displays offline pack cache with size/age/source info; (9) `pack cache add` pre-fetches pack to local cache for offline execution; (10) `pack cache prune` cleans cache with --max-age/--max-size/--all options. Extended `PackModels.cs` with `PackRunListRequest/Response`, `PackCancelRequest`, `PackApprovalPauseRequest`, `PackApprovalResumeRequest`, `PackApprovalResult`, `PackLogsRequest`, `PackLogEntry`, `PackLogsResult`, `PackSecretInjectRequest/Result`, `PackArtifactDownloadRequest/Result`, `PackCacheEntry`, `PackCacheRequest/Result` models. Extended `IPackClient` and `PackClient` with 8 new operations. Added handlers to `CommandHandlers.cs` with Spectre.Console rich output for runs tables, log streaming, and cache management. Dependencies: CLI-PACKS-42-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PARITY-41-001 | DONE (2025-11-28) | Implemented `stella sbom` command group with full SBOM explorer and parity matrix features. Commands: (1) `sbom list` lists SBOMs with filters for image-ref, digest, format (spdx/cyclonedx), creation date range, vulnerability presence, with pagination and determinism score display; (2) `sbom show` displays detailed SBOM info with --components, --vulnerabilities, --licenses, and --explain options for determinism factors and composition path debugging; (3) `sbom compare` compares two SBOMs showing component/vulnerability/license diffs with added/removed/modified change tracking; (4) `sbom export` exports SBOM in SPDX or CycloneDX format with --format-version, --signed attestation, --include-vex options, supports stdout or file output; (5) `sbom parity-matrix` displays CLI command coverage matrix with deterministic, --explain, and offline capability tracking. Created `SbomModels.cs` with comprehensive models for SBOM summary/detail, components, vulnerabilities, licenses, attestation, determinism factors, composition path, comparison, export, and parity matrix. Added `ISbomClient` interface and `SbomClient` implementation with HTTP client for SBOM APIs. Extended `CliError` with ERR_SBOM_* codes (exit 18). Registered client in `Program.cs`. Added handlers to `CommandHandlers.cs` with Spectre.Console rich output for SBOM tables, detail panels, comparison summaries, and parity matrix display. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-PARITY-41-002 | DONE (2025-11-28) | Implemented `notify` command group with comprehensive notification management capabilities. Commands: (1) `notify channels list` lists notification channels with type/enabled filters, pagination, failure rate display; (2) `notify channels show` displays detailed channel info with config, stats, health, and labels; (3) `notify channels test` sends test message to channel with latency and success reporting; (4) `notify rules list` lists routing rules with event-type/channel/enabled filters; (5) `notify deliveries list` lists deliveries with status/event-type/channel/date-range filters and pagination; (6) `notify deliveries show` displays detailed delivery info with attempt history; (7) `notify deliveries retry` retries failed delivery with idempotency key support; (8) `notify send` sends notification via rules or direct channel with event-type, subject, severity, metadata, and idempotency key. Created `NotifyModels.cs` with `NotifyChannelListRequest/Response`, `NotifyChannelSummary/Detail`, `NotifyChannelConfigInfo/Limits/Stats/Health`, `NotifyChannelTestRequest/Result`, `NotifyRuleListRequest/Response/Summary`, `NotifyDeliveryListRequest/Response`, `NotifyDeliverySummary/Detail/Attempt`, `NotifyRetryRequest/Result`, `NotifySendRequest/Result` models. Added `INotifyClient` interface and `NotifyClient` implementation with HTTP client supporting Idempotency-Key headers for mutation operations. Extended `CliError` with ERR_NOTIFY_* codes (exit 19). Registered client in `Program.cs`. Added handlers to `CommandHandlers.cs` with Spectre.Console rich output for channel tables, delivery status, health indicators, and attempt history. Note: `aoc` and `auth` commands already exist in the CLI. Dependencies: CLI-PARITY-41-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SBOM-60-001 | DONE (2025-11-28) | Implemented `stella sbomer` command group for deterministic SBOM composition. Commands: (1) `sbomer layer list` lists layer fragments for a scan with DSSE signature status; (2) `sbomer layer show` displays fragment details with --components and --dsse options for components list and DSSE envelope/signature info; (3) `sbomer layer verify` verifies fragment DSSE signature and content hash with offline mode support; (4) `sbomer compose` composes SBOM from layer fragments with canonical ordering, emits _composition.json manifest and Merkle diagnostics, supports --verify for fragment verification before compose; (5) `sbomer composition show` displays composition manifest with fragment canonical order and properties; (6) `sbomer composition verify` verifies composition against manifest, recomputes Merkle root, and validates all fragment signatures with --recompose option; (7) `sbomer composition merkle` shows Merkle tree diagnostics with leaves and intermediate nodes. Created `SbomerModels.cs` with `SbomFragment`, `SbomFragmentComponent`, `DsseEnvelopeInfo`, `DsseSignatureInfo`, `MerkleProofInfo`, `CompositionManifest`, `CompositionFragmentEntry`, `MerkleDiagnostics`, `MerkleLeafInfo`, `MerkleNodeInfo`, request/response/result types. Added `ISbomerClient` interface and `SbomerClient` implementation. Extended `CliError` with ERR_SBOMER_* codes (exit 20). Registered client in `Program.cs`. Added handlers to `CommandHandlers.cs` with Spectre.Console rich output for layer tables, DSSE signatures, Merkle trees, and composition manifests. Dependencies: CLI-PARITY-41-001, SCANNER-SURFACE-04. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SBOM-60-002 | DONE (2025-11-28) | Implemented `stella sbomer drift` command group with analyze and verify subcommands for drift detection and explanation. Commands: (1) `sbomer drift analyze` (alias: `diff`) compares current SBOM against baseline, detects component/ordering/timestamp/key/whitespace drifts, reports determinism-breaking changes with severity levels, supports `--explain` for detailed root cause analysis with remediation suggestions; (2) `sbomer drift verify` performs local recomposition from offline kit bundles, validates fragment DSSE signatures (`--validate-fragments`), checks Merkle proofs (`--check-merkle`), compares recomposed hash against stored hash, displays offline kit metadata. Extended `SbomerModels.cs` with `SbomerDriftRequest`, `SbomerDriftResult`, `DriftSummary`, `DriftDetail`, `DriftExplanation`, `SbomerDriftVerifyRequest`, `SbomerDriftVerifyResult`, `OfflineKitInfo` models. Extended `ISbomerClient` and `SbomerClient` with `AnalyzeDriftAsync`/`VerifyDriftAsync`. Added drift subcommands to `CommandFactory.cs` and handlers to `CommandHandlers.cs` with Spectre.Console rich output for drift tables, explanation panels, verification status, and offline kit info. Dependencies: CLI-SBOM-60-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-20-001 | DONE (2025-11-28) | Implemented `stella policy new` command for scaffolding new policy files from templates. Features: (1) Creates policy DSL files with metadata, settings, and template-specific rules; (2) Six templates available: minimal (stub), baseline (severity normalization), vex-precedence (VEX handling), reachability (telemetry-aware), secret-leak (secret detection), full (comprehensive); (3) Options: --template/-t for template selection, --description/-d for metadata, --tag for tags, --shadow to enable shadow mode (default), --fixtures to create test fixtures directory, --git-init to initialize Git repository; (4) JSON output support for scripting. Created `PolicyWorkspaceModels.cs` with `PolicyNewRequest`, `PolicyNewResult`, `PolicyTemplate` enum. Added `policy new` command to `CommandFactory.cs` and `HandlePolicyNewAsync` handler to `CommandHandlers.cs` with Spectre.Console rich output and next-steps guidance. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-23-004 | DONE (prior) | The `stella policy lint` command already exists, validating policy DSL files with compiler diagnostics and JSON output support. No additional implementation needed. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
> 2025-11-06: CLI enforces `--version` as mandatory and adds scheduled activation timestamp normalization tests while keeping exit codes intact.
|
||||
CLI-POLICY-23-006 | TODO | Provide `stella policy history` and `stella policy explain` commands to pull run history and explanation trees. Dependencies: CLI-POLICY-23-005. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-001 | TODO | Implement policy workspace commands (`stella policy init`, `edit`, `lint`, `compile`, `test`) with template selection, local cache, JSON output, and deterministic temp directories. Dependencies: CLI-POLICY-23-006. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-23-006 | DONE (2025-11-28) | Implemented `stella policy history` and `stella policy explain` commands. History features: (1) Lists policy runs with run ID, version, status, start time, duration, SBOM count, findings generated/changed; (2) Filters: --tenant, --from/--to date range, --status; (3) Pagination with --limit and --cursor; (4) Color-coded status display. Explain features: (1) Shows policy decision tree for component+advisory tuple; (2) Displays subject info (PURL, component, advisory); (3) Shows decision outcome with status, severity, winning rule, rationale; (4) Rule evaluation trace with priority ordering, predicate evaluation details (verbose mode), action execution results, because clauses; (5) Color-coded matched/evaluated/skipped indicators. Extended `PolicyWorkspaceModels.cs` with `PolicyHistoryRequest`, `PolicyHistoryResponse`, `PolicyRunSummary`, `PolicyExplainRequest`, `PolicyExplainResult`, `PolicyExplainSubject`, `PolicyDecision`, `PolicyRuleTraceEntry`, `PolicyPredicateEvaluation`, `PolicyActionResult`, `PolicyInputContext`. Extended `IBackendOperationsClient` and `BackendOperationsClient` with `GetPolicyHistoryAsync`/`GetPolicyExplainAsync`. Added commands to `CommandFactory.cs` and handlers to `CommandHandlers.cs`. Dependencies: CLI-POLICY-23-005. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-001 | DONE (2025-11-28) | Implemented policy workspace commands. Commands: (1) `stella policy init [path]` initializes a policy workspace directory with policy file, test fixtures, README, .gitignore, and optional Git init; (2) `stella policy compile <file>` compiles policy DSL to IR JSON with digest output, supports --no-ir for validation only, --no-digest, --optimize, --strict (warnings as errors). Init options: --name for policy name, --template for template selection, --no-git/--no-readme/--no-fixtures to skip components. Compile options: --output for IR path, format selection. Edit, lint, and test commands already existed. Created workspace models in `PolicyWorkspaceModels.cs`: `PolicyWorkspaceInitRequest`, `PolicyWorkspaceInitResult`, `PolicyCompileRequest`, `PolicyCompileResult`, `PolicyDiagnostic`. Added commands to `CommandFactory.cs` and handlers `HandlePolicyInitAsync`/`HandlePolicyCompileAsync` to `CommandHandlers.cs`. Dependencies: CLI-POLICY-23-006. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
|
||||
@@ -7,18 +7,18 @@ Depends on: Sprint 180.A - Cli.III
|
||||
Summary: Experience & SDKs focus on Cli (phase IV).
|
||||
Task ID | State | Task description | Owners (Source)
|
||||
--- | --- | --- | ---
|
||||
CLI-POLICY-27-002 | TODO | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. Dependencies: CLI-POLICY-27-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-003 | TODO | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. Dependencies: CLI-POLICY-27-002. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-004 | TODO | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. Dependencies: CLI-POLICY-27-003. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-005 | TODO | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. Dependencies: CLI-POLICY-27-004. | DevEx/CLI Guild, Docs Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-006 | TODO | Update CLI policy profiles/help text to request the new Policy Studio scope family, surface ProblemDetails guidance for `invalid_scope`, and adjust regression tests for scope failures. Dependencies: CLI-POLICY-27-005. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-66-001 | TODO | Implement `stella risk profile list | DevEx/CLI Guild, Policy Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-66-002 | TODO | Ship `stella risk simulate` supporting SBOM/asset inputs, diff mode, and export to JSON/CSV. Dependencies: CLI-RISK-66-001. | DevEx/CLI Guild, Risk Engine Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-67-001 | TODO | Provide `stella risk results` with filtering, severity thresholds, explainability fetch. Dependencies: CLI-RISK-66-002. | DevEx/CLI Guild, Findings Ledger Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-68-001 | TODO | Add `stella risk bundle verify` and integrate with offline risk bundles. Dependencies: CLI-RISK-67-001. | DevEx/CLI Guild, Export Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-62-001 | TODO | Replace bespoke HTTP clients with official SDK (TS/Go) for all CLI commands; ensure modular transport for air-gapped mode. | DevEx/CLI Guild, SDK Generator Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-62-002 | TODO | Update CLI error handling to surface standardized API error envelope with `error.code` and `trace_id`. Dependencies: CLI-SDK-62-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-63-001 | TODO | Expose `stella api spec download` command retrieving aggregate OAS and verifying checksum/ETag. Dependencies: CLI-SDK-62-002. | DevEx/CLI Guild, API Governance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-64-001 | TODO | Add CLI subcommand `stella sdk update` to fetch latest SDK manifests/changelogs; integrate with Notifications for deprecations. Dependencies: CLI-SDK-63-001. | DevEx/CLI Guild, SDK Release Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SIG-26-001 | TODO | Implement `stella reachability upload-callgraph` and `stella reachability list/explain` commands with streaming upload, pagination, and exit codes. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SIG-26-002 | TODO | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). Dependencies: CLI-SIG-26-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-002 | DONE | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. Dependencies: CLI-POLICY-27-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-003 | DONE | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. Dependencies: CLI-POLICY-27-002. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-004 | DONE | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. Dependencies: CLI-POLICY-27-003. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-005 | DONE | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. Dependencies: CLI-POLICY-27-004. | DevEx/CLI Guild, Docs Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-POLICY-27-006 | DONE | Update CLI policy profiles/help text to request the new Policy Studio scope family, surface ProblemDetails guidance for `invalid_scope`, and adjust regression tests for scope failures. Dependencies: CLI-POLICY-27-005. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-66-001 | DONE | Implement `stella risk profile list` with category filtering, pagination, and JSON output. | DevEx/CLI Guild, Policy Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-66-002 | DONE | Ship `stella risk simulate` supporting SBOM/asset inputs, diff mode, and export to JSON/CSV. Dependencies: CLI-RISK-66-001. | DevEx/CLI Guild, Risk Engine Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-67-001 | DONE | Provide `stella risk results` with filtering, severity thresholds, explainability fetch. Dependencies: CLI-RISK-66-002. | DevEx/CLI Guild, Findings Ledger Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-RISK-68-001 | DONE | Add `stella risk bundle verify` and integrate with offline risk bundles. Dependencies: CLI-RISK-67-001. | DevEx/CLI Guild, Export Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-62-001 | DONE | Replace bespoke HTTP clients with official SDK (TS/Go) for all CLI commands; ensure modular transport for air-gapped mode. | DevEx/CLI Guild, SDK Generator Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-62-002 | DONE | Update CLI error handling to surface standardized API error envelope with `error.code` and `trace_id`. Dependencies: CLI-SDK-62-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-63-001 | DONE | Expose `stella api spec download` command retrieving aggregate OAS and verifying checksum/ETag. Dependencies: CLI-SDK-62-002. | DevEx/CLI Guild, API Governance Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SDK-64-001 | DONE | Add CLI subcommand `stella sdk update` to fetch latest SDK manifests/changelogs; integrate with Notifications for deprecations. Dependencies: CLI-SDK-63-001. | DevEx/CLI Guild, SDK Release Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SIG-26-001 | DONE | Implement `stella reachability upload-callgraph` and `stella reachability list/explain` commands with streaming upload, pagination, and exit codes. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
CLI-SIG-26-002 | DONE | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). Dependencies: CLI-SIG-26-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
||||
183
docs/modules/cli/guides/commands/api.md
Normal file
183
docs/modules/cli/guides/commands/api.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# stella api — Command Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The `stella api` command group provides API management capabilities including specification download and listing.
|
||||
|
||||
## Commands
|
||||
|
||||
### List API Specifications (CLI-SDK-63-001)
|
||||
|
||||
```bash
|
||||
# List available API specifications
|
||||
stella api spec list \
|
||||
[--tenant <id>] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--tenant` / `-t` | Tenant context for the operation |
|
||||
| `--json` | Output in JSON format |
|
||||
|
||||
**Output:**
|
||||
- Aggregate API specification details (version, OpenAPI version, ETag, SHA-256)
|
||||
- Service-level specifications with version and format information
|
||||
|
||||
### Download API Specification (CLI-SDK-63-001)
|
||||
|
||||
```bash
|
||||
# Download API specification
|
||||
stella api spec download \
|
||||
--output <path> \
|
||||
[--tenant <id>] \
|
||||
[--service <name>] \
|
||||
[--format openapi-json|openapi-yaml] \
|
||||
[--overwrite] \
|
||||
[--etag <etag>] \
|
||||
[--checksum <checksum>] \
|
||||
[--checksum-algorithm sha256|sha384|sha512] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--output` / `-o` | Output path for the downloaded spec (file or directory) (required) |
|
||||
| `--service` / `-s` | Service to download spec for (e.g., concelier, scanner, policy). Omit for aggregate spec |
|
||||
| `--format` / `-f` | Output format: `openapi-json` (default) or `openapi-yaml` |
|
||||
| `--overwrite` | Overwrite existing file if present |
|
||||
| `--etag` | Expected ETag for conditional download (If-None-Match) |
|
||||
| `--checksum` | Expected checksum for verification after download |
|
||||
| `--checksum-algorithm` | Checksum algorithm: `sha256` (default), `sha384`, `sha512` |
|
||||
|
||||
**Output:**
|
||||
- Downloaded file path
|
||||
- File size
|
||||
- API version (extracted from spec)
|
||||
- ETag for future conditional downloads
|
||||
- Checksum with verification status
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Error or download failure |
|
||||
| 130 | Operation cancelled by user |
|
||||
|
||||
## JSON Schema: ApiSpecDownloadResult
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"path": { "type": "string" },
|
||||
"sizeBytes": { "type": "integer" },
|
||||
"fromCache": { "type": "boolean" },
|
||||
"etag": { "type": "string" },
|
||||
"checksum": { "type": "string" },
|
||||
"checksumAlgorithm": { "type": "string" },
|
||||
"checksumVerified": { "type": "boolean" },
|
||||
"apiVersion": { "type": "string" },
|
||||
"generatedAt": { "type": "string", "format": "date-time" },
|
||||
"error": { "type": "string" },
|
||||
"errorCode": { "type": "string" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### List available API specifications
|
||||
|
||||
```bash
|
||||
# List all specs
|
||||
stella api spec list
|
||||
|
||||
# List specs as JSON
|
||||
stella api spec list --json
|
||||
```
|
||||
|
||||
### Download aggregate specification
|
||||
|
||||
```bash
|
||||
# Download aggregate OpenAPI spec to current directory
|
||||
stella api spec download --output ./
|
||||
|
||||
# Download with checksum verification
|
||||
stella api spec download \
|
||||
--output ./stellaops-api.json \
|
||||
--checksum abc123def456... \
|
||||
--checksum-algorithm sha256
|
||||
```
|
||||
|
||||
### Download service-specific specification
|
||||
|
||||
```bash
|
||||
# Download Scanner API spec
|
||||
stella api spec download \
|
||||
--output ./scanner-api.yaml \
|
||||
--service scanner \
|
||||
--format openapi-yaml
|
||||
```
|
||||
|
||||
### Conditional download with ETag
|
||||
|
||||
```bash
|
||||
# First download captures ETag
|
||||
stella api spec download --output ./api.json --json > download-result.json
|
||||
|
||||
# Subsequent downloads use ETag for cache validation
|
||||
ETAG=$(jq -r '.etag' download-result.json)
|
||||
stella api spec download \
|
||||
--output ./api.json \
|
||||
--etag "$ETAG"
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Download and validate API spec in CI
|
||||
|
||||
stella api spec download \
|
||||
--output ./openapi.json \
|
||||
--checksum "$EXPECTED_CHECKSUM" \
|
||||
--json > result.json
|
||||
|
||||
if [ "$(jq -r '.checksumVerified' result.json)" != "true" ]; then
|
||||
echo "API spec checksum verification failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate client code from spec
|
||||
npx openapi-generator-cli generate \
|
||||
-i ./openapi.json \
|
||||
-g typescript-fetch \
|
||||
-o ./generated-client
|
||||
```
|
||||
|
||||
## Available Services
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| `aggregate` | Combined specification from all services (default) |
|
||||
| `concelier` | Vulnerability advisory and VEX management |
|
||||
| `scanner` | Container scanning and SBOM generation |
|
||||
| `policy` | Policy engine and evaluation |
|
||||
| `authority` | Authentication and authorization |
|
||||
| `attestor` | Attestation generation and verification |
|
||||
| `notify` | Notification delivery |
|
||||
| `scheduler` | Job scheduling |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use ETag for conditional downloads** to minimize bandwidth and improve CI performance
|
||||
2. **Verify checksums** when downloading specs for code generation in production pipelines
|
||||
3. **Download aggregate spec** for general client generation; service-specific specs for targeted APIs
|
||||
4. **Store ETags** in CI cache to enable incremental downloads
|
||||
5. **Use YAML format** for human readability; JSON for programmatic processing
|
||||
@@ -1,25 +1,332 @@
|
||||
# stella policy — Command Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The `stella policy` command group provides comprehensive policy management capabilities for Policy Studio, including creation, simulation, workflow management, and lifecycle operations.
|
||||
|
||||
## Commands
|
||||
- `stella policy eval --input <bundle> --subject <sbom|vex|vuln> [--offline] [--output json|ndjson|table]`
|
||||
- `stella policy simulate --from <bundleA> --to <bundleB> [--budget <ms>] [--offline]`
|
||||
- `stella policy publish --input <bundle> --sign --attest`
|
||||
|
||||
## Flags (common)
|
||||
- `--offline` / `STELLA_OFFLINE=1`: forbid network calls; use cached bundles only.
|
||||
- `--tenant <id>`: scope evaluation to tenant; RLS enforcement required on the server.
|
||||
- `--rationale`: include rationale IDs in responses.
|
||||
- `--output`: `json` (default), `ndjson`, or `table`.
|
||||
### Policy Creation & Scaffolding
|
||||
|
||||
## Inputs/outputs
|
||||
- Inputs: policy bundles (signed), subject artifacts (SBOM/VEX/Vuln snapshots).
|
||||
- Outputs: deterministic JSON/NDJSON or tables; includes `correlationId`, `policyVersion`, `rationaleIds` when requested.
|
||||
- Exit codes follow `output-and-exit-codes.md`.
|
||||
```bash
|
||||
# Create a new policy from a template
|
||||
stella policy new <name> [--template <template>] [--output <path>] [--description <desc>] [--tags <tag1,tag2>] [--shadow-mode] [--create-fixtures] [--git-init]
|
||||
```
|
||||
|
||||
## Determinism rules
|
||||
- Sort evaluation results by subject key; timestamps UTC ISO-8601.
|
||||
- No inferred verdicts beyond Policy Engine response.
|
||||
**Templates:** `basic`, `sbom-gate`, `vex-precedence`, `reachability`, `secret-detection`, `license-compliance`, `supply-chain`
|
||||
|
||||
## Offline/air-gap notes
|
||||
- When `--offline`, evaluation must use locally cached bundles and subject artifacts; fail with exit code 5 if network would be needed.
|
||||
- Trust roots loaded from `STELLA_TRUST_ROOTS` when verifying signed bundles.
|
||||
### Policy Simulation (CLI-POLICY-27-003)
|
||||
|
||||
```bash
|
||||
# Simulate policy changes with enhanced options
|
||||
stella policy simulate <policy-id> \
|
||||
[--base <version>] \
|
||||
[--candidate <version>] \
|
||||
[--sbom <id1,id2,...>] \
|
||||
[--env key=value] \
|
||||
[--mode quick|batch] \
|
||||
[--sbom-selector <pattern>] \
|
||||
[--heatmap] \
|
||||
[--manifest-download] \
|
||||
[--reachability-state <id:state>] \
|
||||
[--reachability-score <id:score>] \
|
||||
[--with-exception <exc-id>] \
|
||||
[--without-exception <exc-id>] \
|
||||
[--explain] \
|
||||
[--fail-on-diff] \
|
||||
[--format json|table|markdown] \
|
||||
[--output <path>]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--mode quick\|batch` | Simulation mode: `quick` samples SBOMs, `batch` evaluates all matching |
|
||||
| `--sbom-selector` | SBOM selector pattern (e.g., `registry:docker.io/*`, `tag:production`). Repeatable |
|
||||
| `--heatmap` | Include severity heatmap summary in output |
|
||||
| `--manifest-download` | Request manifest download URI for offline analysis |
|
||||
| `--reachability-state` | Override reachability state (format: `CVE-XXXX:reachable`). Repeatable |
|
||||
| `--reachability-score` | Override reachability score (format: `CVE-XXXX:0.85`). Repeatable |
|
||||
| `--format markdown` | Generate CI-friendly markdown report |
|
||||
|
||||
### Policy Workflow (CLI-POLICY-27-002)
|
||||
|
||||
```bash
|
||||
# Bump policy version
|
||||
stella policy version bump <policy-id> [--changelog <message>] [--major|--minor|--patch]
|
||||
|
||||
# Submit policy for review
|
||||
stella policy submit <policy-id> [--version <ver>] [--reviewers <user1,user2>] [--changelog <message>]
|
||||
|
||||
# Add review comment
|
||||
stella policy review comment <policy-id> [--version <ver>] --comment <text> [--line <num>] [--file <path>]
|
||||
|
||||
# Approve policy review
|
||||
stella policy approve <policy-id> [--version <ver>] [--comment <text>]
|
||||
|
||||
# Reject policy review
|
||||
stella policy reject <policy-id> [--version <ver>] --reason <text>
|
||||
|
||||
# Get review status
|
||||
stella policy review status <policy-id> [--version <ver>]
|
||||
```
|
||||
|
||||
### Policy Lifecycle (CLI-POLICY-27-004)
|
||||
|
||||
```bash
|
||||
# Publish policy
|
||||
stella policy publish <policy-id> [--version <ver>] [--sign] [--attestation-type <type>] [--dry-run]
|
||||
|
||||
# Promote policy to environment
|
||||
stella policy promote <policy-id> [--version <ver>] --env <environment> [--canary <percentage>] [--dry-run]
|
||||
|
||||
# Rollback policy
|
||||
stella policy rollback <policy-id> [--to-version <ver>] [--reason <text>] [--force]
|
||||
|
||||
# Sign policy
|
||||
stella policy sign <policy-id> [--version <ver>] [--key-id <key>] [--attestation-type <type>]
|
||||
|
||||
# Verify policy signature
|
||||
stella policy verify-signature <policy-id> [--version <ver>] [--check-rekor]
|
||||
```
|
||||
|
||||
### Policy History & Explain (CLI-POLICY-23-006)
|
||||
|
||||
```bash
|
||||
# Get policy history
|
||||
stella policy history <policy-id> [--limit <num>] [--since <date>] [--until <date>]
|
||||
|
||||
# Explain policy decision
|
||||
stella policy explain <policy-id> [--version <ver>] [--finding-id <id>] [--verbose]
|
||||
```
|
||||
|
||||
### Policy Activation
|
||||
|
||||
```bash
|
||||
# Activate an approved policy revision
|
||||
stella policy activate <policy-id> --version <ver> [--environment <env>] [--force] [--dry-run]
|
||||
```
|
||||
|
||||
## Common Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--tenant` / `-t` | Tenant context for the operation |
|
||||
| `--json` | Output as JSON |
|
||||
| `--verbose` / `-v` | Enable verbose logging |
|
||||
| `--offline` | Forbid network calls; use cached bundles only |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | General error |
|
||||
| 4 | Input validation error |
|
||||
| 5 | Network required but offline mode enabled |
|
||||
| 20 | Differences detected with `--fail-on-diff` |
|
||||
| 130 | Operation cancelled by user |
|
||||
|
||||
## JSON Schemas
|
||||
|
||||
### PolicySimulationResult
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diff": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schemaVersion": { "type": "string" },
|
||||
"added": { "type": "integer" },
|
||||
"removed": { "type": "integer" },
|
||||
"unchanged": { "type": "integer" },
|
||||
"bySeverity": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"up": { "type": "integer" },
|
||||
"down": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ruleHits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ruleId": { "type": "string" },
|
||||
"ruleName": { "type": "string" },
|
||||
"up": { "type": "integer" },
|
||||
"down": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"explainUri": { "type": "string" },
|
||||
"heatmap": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"buckets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"severity": { "type": "string" },
|
||||
"count": { "type": "integer" },
|
||||
"percentage": { "type": "number" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"total": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"manifestDownloadUri": { "type": "string" },
|
||||
"manifestDigest": { "type": "string" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PolicyReviewSummary
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"policyId": { "type": "string" },
|
||||
"version": { "type": "integer" },
|
||||
"status": { "type": "string", "enum": ["pending", "approved", "rejected", "changes_requested"] },
|
||||
"submittedBy": { "type": "string" },
|
||||
"submittedAt": { "type": "string", "format": "date-time" },
|
||||
"reviewers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"reviewedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"comments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commentId": { "type": "string" },
|
||||
"author": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"line": { "type": "integer" },
|
||||
"file": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CI/CD Integration Examples
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Policy Simulation
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'policies/**'
|
||||
|
||||
jobs:
|
||||
simulate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Stella CLI
|
||||
run: |
|
||||
curl -sSL https://get.stellaops.io | bash
|
||||
|
||||
- name: Simulate Policy Changes
|
||||
run: |
|
||||
stella policy simulate P-7 \
|
||||
--base $(git merge-base HEAD origin/main) \
|
||||
--candidate HEAD \
|
||||
--mode batch \
|
||||
--heatmap \
|
||||
--format markdown \
|
||||
--output simulation-report.md \
|
||||
--fail-on-diff
|
||||
|
||||
- name: Upload Report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: policy-simulation-report
|
||||
path: simulation-report.md
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
||||
```yaml
|
||||
policy-simulate:
|
||||
stage: test
|
||||
script:
|
||||
- stella policy simulate P-7 --mode quick --heatmap --json > simulation.json
|
||||
- |
|
||||
if [ $(jq '.diff.added + .diff.removed' simulation.json) -gt 0 ]; then
|
||||
echo "Policy changes detected"
|
||||
stella policy simulate P-7 --format markdown --output report.md
|
||||
exit 20
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- simulation.json
|
||||
- report.md
|
||||
when: always
|
||||
```
|
||||
|
||||
### Azure DevOps
|
||||
|
||||
```yaml
|
||||
- task: Bash@3
|
||||
displayName: 'Policy Simulation'
|
||||
inputs:
|
||||
targetType: 'inline'
|
||||
script: |
|
||||
stella policy simulate P-7 \
|
||||
--mode batch \
|
||||
--sbom-selector "registry:$(ACR_REGISTRY)/*" \
|
||||
--heatmap \
|
||||
--json \
|
||||
--output $(Build.ArtifactStagingDirectory)/simulation.json
|
||||
```
|
||||
|
||||
## Determinism Rules
|
||||
|
||||
- Sort evaluation results by subject key
|
||||
- Timestamps use UTC ISO-8601 format
|
||||
- No inferred verdicts beyond Policy Engine response
|
||||
- Hashes computed with SHA-256
|
||||
|
||||
## Offline/Air-Gap Notes
|
||||
|
||||
- When `--offline` is set, evaluation uses locally cached bundles and subject artifacts
|
||||
- Fails with exit code 5 if network would be needed
|
||||
- Trust roots loaded from `STELLA_TRUST_ROOTS` environment variable when verifying signed bundles
|
||||
- Signature verification can use local Rekor mirror via `STELLA_REKOR_MIRROR`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `STELLAOPS_BACKEND_URL` | Backend API URL |
|
||||
| `STELLA_OFFLINE` | Set to `1` to enable offline mode |
|
||||
| `STELLA_TRUST_ROOTS` | Path to trust roots for signature verification |
|
||||
| `STELLA_REKOR_MIRROR` | Local Rekor transparency log mirror URL |
|
||||
| `STELLAOPS_TENANT` | Default tenant context |
|
||||
|
||||
265
docs/modules/cli/guides/commands/reachability.md
Normal file
265
docs/modules/cli/guides/commands/reachability.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# stella reachability — Command Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The `stella reachability` command group provides reachability analysis capabilities for vulnerability exploitability assessment. It supports call graph upload, analysis listing, and detailed reachability explanations.
|
||||
|
||||
## Commands
|
||||
|
||||
### Upload Call Graph (CLI-SIG-26-001)
|
||||
|
||||
```bash
|
||||
# Upload a call graph for reachability analysis
|
||||
stella reachability upload-callgraph \
|
||||
--path <call-graph-file> \
|
||||
[--tenant <id>] \
|
||||
[--scan-id <id>] \
|
||||
[--asset-id <id>] \
|
||||
[--format auto|json|proto|dot] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--path` / `-p` | Path to the call graph file (required) |
|
||||
| `--scan-id` | Scan identifier to associate with the call graph |
|
||||
| `--asset-id` / `-a` | Asset identifier to associate with the call graph |
|
||||
| `--format` / `-f` | Call graph format: `auto` (default), `json`, `proto`, `dot` |
|
||||
|
||||
**Required:** At least one of `--scan-id` or `--asset-id`.
|
||||
|
||||
**Supported Call Graph Formats:**
|
||||
- JSON (native format)
|
||||
- Protocol Buffers (proto)
|
||||
- DOT/GraphViz format
|
||||
|
||||
### List Reachability Analyses (CLI-SIG-26-001)
|
||||
|
||||
```bash
|
||||
# List reachability analyses
|
||||
stella reachability list \
|
||||
[--tenant <id>] \
|
||||
[--scan-id <id>] \
|
||||
[--asset-id <id>] \
|
||||
[--status pending|processing|completed|failed] \
|
||||
[--limit <num>] \
|
||||
[--offset <num>] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--scan-id` | Filter by scan identifier |
|
||||
| `--asset-id` / `-a` | Filter by asset identifier |
|
||||
| `--status` | Filter by analysis status |
|
||||
| `--limit` / `-l` | Maximum number of results (default 100) |
|
||||
| `--offset` / `-o` | Pagination offset |
|
||||
|
||||
**Output Columns:**
|
||||
- Analysis ID
|
||||
- Asset name/ID
|
||||
- Status (pending, processing, completed, failed)
|
||||
- Reachable count
|
||||
- Unreachable count
|
||||
- Unknown count
|
||||
- Created timestamp
|
||||
|
||||
### Explain Reachability (CLI-SIG-26-001)
|
||||
|
||||
```bash
|
||||
# Explain reachability for a vulnerability or package
|
||||
stella reachability explain \
|
||||
--analysis-id <id> \
|
||||
[--tenant <id>] \
|
||||
[--vuln-id <cve-id>] \
|
||||
[--purl <package-url>] \
|
||||
[--call-paths] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--analysis-id` / `-i` | Analysis identifier (required) |
|
||||
| `--vuln-id` / `-v` | Vulnerability identifier to explain |
|
||||
| `--purl` | Package URL to explain |
|
||||
| `--call-paths` | Include detailed call paths in the explanation |
|
||||
|
||||
**Required:** At least one of `--vuln-id` or `--purl`.
|
||||
|
||||
**Output:**
|
||||
- Reachability state (reachable, unreachable, unknown)
|
||||
- Reachability score (0-1)
|
||||
- Confidence level
|
||||
- Reasoning explanation
|
||||
- Affected functions list
|
||||
- Call paths (when `--call-paths` is used)
|
||||
|
||||
## Integration with Policy Simulation (CLI-SIG-26-002)
|
||||
|
||||
Reachability overrides can be applied during policy simulation:
|
||||
|
||||
```bash
|
||||
stella policy simulate P-7 \
|
||||
--reachability-state "CVE-2024-1234:unreachable" \
|
||||
--reachability-state "pkg:npm/lodash@4.17.0:reachable" \
|
||||
--reachability-score "CVE-2024-5678:0.25"
|
||||
```
|
||||
|
||||
**Override Format:**
|
||||
- State: `<identifier>:<state>` where state is `reachable`, `unreachable`, `unknown`, or `indeterminate`
|
||||
- Score: `<identifier>:<score>` where score is a decimal between 0 and 1
|
||||
|
||||
**Identifier Types:**
|
||||
- Vulnerability ID: `CVE-XXXX-XXXX`, `GHSA-xxxx-xxxx-xxxx`
|
||||
- Package URL: `pkg:npm/package@version`, `pkg:maven/group/artifact@version`
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Error or upload failure |
|
||||
| 4 | Input validation error |
|
||||
| 130 | Operation cancelled by user |
|
||||
|
||||
## JSON Schema: ReachabilityExplainResult
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"analysisId": { "type": "string" },
|
||||
"vulnerabilityId": { "type": "string" },
|
||||
"packagePurl": { "type": "string" },
|
||||
"reachabilityState": {
|
||||
"type": "string",
|
||||
"enum": ["reachable", "unreachable", "unknown", "indeterminate"]
|
||||
},
|
||||
"reachabilityScore": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"confidence": { "type": "string" },
|
||||
"reasoning": { "type": "string" },
|
||||
"callPaths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pathId": { "type": "string" },
|
||||
"depth": { "type": "integer" },
|
||||
"entryPoint": { "$ref": "#/$defs/function" },
|
||||
"frames": { "type": "array", "items": { "$ref": "#/$defs/function" } },
|
||||
"vulnerableFunction": { "$ref": "#/$defs/function" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"affectedFunctions": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/function" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"function": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"signature": { "type": "string" },
|
||||
"className": { "type": "string" },
|
||||
"packageName": { "type": "string" },
|
||||
"filePath": { "type": "string" },
|
||||
"lineNumber": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Upload a call graph
|
||||
|
||||
```bash
|
||||
# Upload call graph for a specific scan
|
||||
stella reachability upload-callgraph \
|
||||
--path ./callgraph.json \
|
||||
--scan-id scan-12345 \
|
||||
--format json
|
||||
|
||||
# Upload with auto-detection
|
||||
stella reachability upload-callgraph \
|
||||
--path ./app-callgraph.dot \
|
||||
--asset-id my-application
|
||||
```
|
||||
|
||||
### List recent analyses
|
||||
|
||||
```bash
|
||||
# List all completed analyses for an asset
|
||||
stella reachability list \
|
||||
--asset-id my-application \
|
||||
--status completed \
|
||||
--json
|
||||
|
||||
# List analyses with pagination
|
||||
stella reachability list \
|
||||
--limit 20 \
|
||||
--offset 40
|
||||
```
|
||||
|
||||
### Explain vulnerability reachability
|
||||
|
||||
```bash
|
||||
# Explain with call paths
|
||||
stella reachability explain \
|
||||
--analysis-id RA-abc123 \
|
||||
--vuln-id CVE-2024-1234 \
|
||||
--call-paths
|
||||
|
||||
# Explain package reachability
|
||||
stella reachability explain \
|
||||
--analysis-id RA-abc123 \
|
||||
--purl "pkg:npm/lodash@4.17.21" \
|
||||
--json
|
||||
```
|
||||
|
||||
### Policy simulation with reachability overrides
|
||||
|
||||
```bash
|
||||
# Mark specific vulnerability as unreachable
|
||||
stella policy simulate P-7 \
|
||||
--reachability-state "CVE-2024-1234:unreachable" \
|
||||
--explain
|
||||
|
||||
# Set low reachability score
|
||||
stella policy simulate P-7 \
|
||||
--reachability-score "pkg:npm/axios@0.21.0:0.1"
|
||||
```
|
||||
|
||||
## Reachability States
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| `reachable` | Vulnerable code is reachable from application entry points |
|
||||
| `unreachable` | Vulnerable code cannot be reached during execution |
|
||||
| `unknown` | Reachability cannot be determined with available information |
|
||||
| `indeterminate` | Analysis inconclusive due to dynamic dispatch or reflection |
|
||||
|
||||
## Call Graph Generation
|
||||
|
||||
Call graphs can be generated using various tools:
|
||||
|
||||
- **Java:** [WALA](https://github.com/wala/WALA), [Soot](https://github.com/soot-oss/soot)
|
||||
- **JavaScript/Node.js:** [callgraph](https://www.npmjs.com/package/callgraph)
|
||||
- **Python:** [pycg](https://github.com/vitsalis/pycg)
|
||||
- **Go:** `go build -gcflags="-m"` + static analysis
|
||||
- **C/C++:** [LLVM](https://llvm.org/) call graph pass
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Upload call graphs after each build** to maintain accurate reachability data
|
||||
2. **Use asset IDs** for long-lived applications to track reachability changes over time
|
||||
3. **Include call paths** when debugging unexpected reachability results
|
||||
4. **Apply reachability overrides** in policy simulation to model remediation scenarios
|
||||
5. **Monitor unreachable counts** as a metric for dependency hygiene
|
||||
248
docs/modules/cli/guides/commands/risk.md
Normal file
248
docs/modules/cli/guides/commands/risk.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# stella risk — Command Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The `stella risk` command group provides risk profile management, risk scoring simulation, and risk bundle verification capabilities.
|
||||
|
||||
## Commands
|
||||
|
||||
### Risk Profile Management (CLI-RISK-66-001)
|
||||
|
||||
```bash
|
||||
# List risk profiles
|
||||
stella risk profile list \
|
||||
[--tenant <id>] \
|
||||
[--include-disabled] \
|
||||
[--category <category>] \
|
||||
[--limit <num>] \
|
||||
[--offset <num>] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--include-disabled` | Include disabled profiles in listing |
|
||||
| `--category` | Filter by profile category |
|
||||
| `--limit` | Maximum number of results (default 100) |
|
||||
| `--offset` | Pagination offset |
|
||||
|
||||
**Output Columns:**
|
||||
- Profile ID
|
||||
- Name
|
||||
- Category
|
||||
- Version
|
||||
- Rules count
|
||||
- Enabled status
|
||||
- Built-in indicator
|
||||
|
||||
### Risk Simulation (CLI-RISK-66-002)
|
||||
|
||||
```bash
|
||||
# Simulate risk scoring
|
||||
stella risk simulate \
|
||||
[--tenant <id>] \
|
||||
[--profile-id <id>] \
|
||||
[--sbom-id <id>] \
|
||||
[--sbom-path <path>] \
|
||||
[--asset-id <id>] \
|
||||
[--diff] \
|
||||
[--baseline-profile-id <id>] \
|
||||
[--json] \
|
||||
[--csv] \
|
||||
[--output <path>]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--profile-id` | Risk profile to use for simulation |
|
||||
| `--sbom-id` | SBOM identifier for risk evaluation |
|
||||
| `--sbom-path` | Local path to SBOM file |
|
||||
| `--asset-id` | Asset identifier for risk evaluation |
|
||||
| `--diff` | Enable diff mode to compare with baseline |
|
||||
| `--baseline-profile-id` | Baseline profile for diff comparison |
|
||||
|
||||
**Required:** At least one of `--sbom-id`, `--sbom-path`, or `--asset-id`.
|
||||
|
||||
**Output:**
|
||||
- Overall score and grade (A+ to F)
|
||||
- Findings summary by severity (critical, high, medium, low, info)
|
||||
- Component-level scores
|
||||
- Diff information when `--diff` is enabled
|
||||
|
||||
### Risk Results (CLI-RISK-67-001)
|
||||
|
||||
```bash
|
||||
# Get risk evaluation results
|
||||
stella risk results \
|
||||
[--tenant <id>] \
|
||||
[--asset-id <id>] \
|
||||
[--sbom-id <id>] \
|
||||
[--profile-id <id>] \
|
||||
[--min-severity <severity>] \
|
||||
[--max-score <score>] \
|
||||
[--explain] \
|
||||
[--limit <num>] \
|
||||
[--offset <num>] \
|
||||
[--json] \
|
||||
[--csv]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--min-severity` | Minimum severity threshold (critical, high, medium, low, info) |
|
||||
| `--max-score` | Maximum score threshold (0-100) |
|
||||
| `--explain` | Include explainability information |
|
||||
|
||||
**Output:**
|
||||
- Summary statistics (average, min, max score, asset count)
|
||||
- Results table with score, grade, severity, finding count
|
||||
- Explanation factors and recommendations when `--explain` is used
|
||||
|
||||
### Risk Bundle Verification (CLI-RISK-68-001)
|
||||
|
||||
```bash
|
||||
# Verify a risk bundle
|
||||
stella risk bundle verify \
|
||||
[--tenant <id>] \
|
||||
--bundle-path <path> \
|
||||
[--signature-path <path>] \
|
||||
[--check-rekor] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--bundle-path` | Path to the risk bundle file (required) |
|
||||
| `--signature-path` | Path to detached signature file |
|
||||
| `--check-rekor` | Verify transparency log entry in Sigstore Rekor |
|
||||
|
||||
**Output:**
|
||||
- Bundle validation status (VALID/INVALID)
|
||||
- Bundle information (ID, version, profile count, rule count)
|
||||
- Signature verification status
|
||||
- Rekor transparency log verification status
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success (for verify: bundle is valid) |
|
||||
| 1 | Error or invalid bundle |
|
||||
| 4 | Input validation error |
|
||||
| 130 | Operation cancelled by user |
|
||||
|
||||
## JSON Schema: RiskSimulateResult
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"profileId": { "type": "string" },
|
||||
"profileName": { "type": "string" },
|
||||
"overallScore": { "type": "number" },
|
||||
"grade": { "type": "string" },
|
||||
"findings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"critical": { "type": "integer" },
|
||||
"high": { "type": "integer" },
|
||||
"medium": { "type": "integer" },
|
||||
"low": { "type": "integer" },
|
||||
"info": { "type": "integer" },
|
||||
"total": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"componentScores": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"componentId": { "type": "string" },
|
||||
"componentName": { "type": "string" },
|
||||
"score": { "type": "number" },
|
||||
"grade": { "type": "string" },
|
||||
"findingCount": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baselineScore": { "type": "number" },
|
||||
"candidateScore": { "type": "number" },
|
||||
"delta": { "type": "number" },
|
||||
"improved": { "type": "boolean" },
|
||||
"findingsAdded": { "type": "integer" },
|
||||
"findingsRemoved": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"simulatedAt": { "type": "string", "format": "date-time" },
|
||||
"errors": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### List all enabled risk profiles
|
||||
|
||||
```bash
|
||||
stella risk profile list --json
|
||||
```
|
||||
|
||||
### Simulate risk for a local SBOM
|
||||
|
||||
```bash
|
||||
stella risk simulate \
|
||||
--sbom-path ./my-sbom.json \
|
||||
--profile-id RP-security-baseline \
|
||||
--json
|
||||
```
|
||||
|
||||
### Compare risk between profiles
|
||||
|
||||
```bash
|
||||
stella risk simulate \
|
||||
--asset-id my-app \
|
||||
--profile-id RP-strict \
|
||||
--diff \
|
||||
--baseline-profile-id RP-permissive
|
||||
```
|
||||
|
||||
### Get high-severity results with explanations
|
||||
|
||||
```bash
|
||||
stella risk results \
|
||||
--asset-id my-app \
|
||||
--min-severity high \
|
||||
--explain
|
||||
```
|
||||
|
||||
### Verify a signed risk bundle
|
||||
|
||||
```bash
|
||||
stella risk bundle verify \
|
||||
--bundle-path ./risk-bundle.tar.gz \
|
||||
--signature-path ./risk-bundle.sig \
|
||||
--check-rekor
|
||||
```
|
||||
|
||||
## Risk Grading Scale
|
||||
|
||||
| Grade | Score Range | Description |
|
||||
|-------|-------------|-------------|
|
||||
| A+ | 95-100 | Excellent |
|
||||
| A | 90-94 | Very Good |
|
||||
| B+ | 85-89 | Good |
|
||||
| B | 80-84 | Above Average |
|
||||
| C+ | 75-79 | Average |
|
||||
| C | 70-74 | Below Average |
|
||||
| D+ | 65-69 | Poor |
|
||||
| D | 60-64 | Very Poor |
|
||||
| F | 0-59 | Failing |
|
||||
249
docs/modules/cli/guides/commands/sdk.md
Normal file
249
docs/modules/cli/guides/commands/sdk.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# stella sdk — Command Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The `stella sdk` command group provides SDK management capabilities including update checking, changelog viewing, and deprecation notices.
|
||||
|
||||
## Commands
|
||||
|
||||
### Check for SDK Updates (CLI-SDK-64-001)
|
||||
|
||||
```bash
|
||||
# Check for SDK updates
|
||||
stella sdk update \
|
||||
[--tenant <id>] \
|
||||
[--language <lang>] \
|
||||
[--check-only] \
|
||||
[--changelog] \
|
||||
[--deprecations] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--tenant` / `-t` | Tenant context for the operation |
|
||||
| `--language` / `-l` | SDK language filter (typescript, go, csharp, python, java). Omit for all |
|
||||
| `--check-only` | Only check for updates, don't download |
|
||||
| `--changelog` | Show changelog for available updates |
|
||||
| `--deprecations` | Show deprecation notices |
|
||||
| `--json` | Output in JSON format |
|
||||
|
||||
**Output:**
|
||||
- Available SDK updates with version comparison
|
||||
- Changelog entries for each update (when `--changelog` is used)
|
||||
- Deprecation notices with migration guidance (when `--deprecations` is used)
|
||||
|
||||
### List Installed SDKs (CLI-SDK-64-001)
|
||||
|
||||
```bash
|
||||
# List installed SDK versions
|
||||
stella sdk list \
|
||||
[--tenant <id>] \
|
||||
[--language <lang>] \
|
||||
[--json]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--tenant` / `-t` | Tenant context for the operation |
|
||||
| `--language` / `-l` | SDK language filter |
|
||||
| `--json` | Output in JSON format |
|
||||
|
||||
**Output:**
|
||||
- Language/platform
|
||||
- Package name
|
||||
- Installed version
|
||||
- Latest available version
|
||||
- API version compatibility range
|
||||
- Update status
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Error |
|
||||
| 130 | Operation cancelled by user |
|
||||
|
||||
## JSON Schema: SdkUpdateResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": { "type": "string" },
|
||||
"displayName": { "type": "string" },
|
||||
"packageName": { "type": "string" },
|
||||
"installedVersion": { "type": "string" },
|
||||
"latestVersion": { "type": "string" },
|
||||
"updateAvailable": { "type": "boolean" },
|
||||
"minApiVersion": { "type": "string" },
|
||||
"maxApiVersion": { "type": "string" },
|
||||
"releaseDate": { "type": "string", "format": "date-time" },
|
||||
"changelog": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"releaseDate": { "type": "string", "format": "date-time" },
|
||||
"type": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"isBreaking": { "type": "boolean" },
|
||||
"link": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadUrl": { "type": "string" },
|
||||
"registryUrl": { "type": "string" },
|
||||
"docsUrl": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": { "type": "string" },
|
||||
"feature": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"deprecatedInVersion": { "type": "string" },
|
||||
"removedInVersion": { "type": "string" },
|
||||
"replacement": { "type": "string" },
|
||||
"migrationGuide": { "type": "string" },
|
||||
"severity": { "type": "string", "enum": ["info", "warning", "critical"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkedAt": { "type": "string", "format": "date-time" },
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Check for all SDK updates
|
||||
|
||||
```bash
|
||||
# Check all SDKs
|
||||
stella sdk update
|
||||
|
||||
# Check with changelog
|
||||
stella sdk update --changelog
|
||||
|
||||
# Check with deprecation notices
|
||||
stella sdk update --deprecations
|
||||
|
||||
# Full check with all details
|
||||
stella sdk update --changelog --deprecations
|
||||
```
|
||||
|
||||
### Check specific language SDK
|
||||
|
||||
```bash
|
||||
# Check TypeScript SDK only
|
||||
stella sdk update --language typescript
|
||||
|
||||
# Check Go SDK with changelog
|
||||
stella sdk update --language go --changelog
|
||||
```
|
||||
|
||||
### List installed SDKs
|
||||
|
||||
```bash
|
||||
# List all installed SDKs
|
||||
stella sdk list
|
||||
|
||||
# List specific language
|
||||
stella sdk list --language python
|
||||
|
||||
# Output as JSON for CI
|
||||
stella sdk list --json
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Check for SDK updates in CI and fail on breaking changes
|
||||
|
||||
stella sdk update --changelog --json > sdk-updates.json
|
||||
|
||||
# Check for breaking changes
|
||||
BREAKING=$(jq '[.updates[].changelog[]? | select(.isBreaking == true)] | length' sdk-updates.json)
|
||||
if [ "$BREAKING" -gt 0 ]; then
|
||||
echo "WARNING: $BREAKING breaking changes detected in available SDK updates"
|
||||
jq '.updates[].changelog[] | select(.isBreaking == true)' sdk-updates.json
|
||||
fi
|
||||
|
||||
# Check for critical deprecations
|
||||
CRITICAL=$(jq '[.deprecations[] | select(.severity == "critical")] | length' sdk-updates.json)
|
||||
if [ "$CRITICAL" -gt 0 ]; then
|
||||
echo "ERROR: $CRITICAL critical deprecations require immediate attention"
|
||||
jq '.deprecations[] | select(.severity == "critical")' sdk-updates.json
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Automated notification script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Send Slack notification when SDK updates are available
|
||||
|
||||
UPDATES=$(stella sdk update --json)
|
||||
UPDATE_COUNT=$(echo "$UPDATES" | jq '[.updates[] | select(.updateAvailable == true)] | length')
|
||||
|
||||
if [ "$UPDATE_COUNT" -gt 0 ]; then
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data "{\"text\": \"StellaOps SDK Updates Available: $UPDATE_COUNT updates\"}" \
|
||||
"$SLACK_WEBHOOK_URL"
|
||||
fi
|
||||
```
|
||||
|
||||
## Supported SDKs
|
||||
|
||||
| Language | Package Name | Registry |
|
||||
|----------|-------------|----------|
|
||||
| TypeScript | `@stellaops/sdk` | npm |
|
||||
| Go | `github.com/stellaops/sdk-go` | Go modules |
|
||||
| C# | `StellaOps.Sdk` | NuGet |
|
||||
| Python | `stellaops-sdk` | PyPI |
|
||||
| Java | `com.stellaops:sdk` | Maven Central |
|
||||
|
||||
## Changelog Entry Types
|
||||
|
||||
| Type | Icon | Description |
|
||||
|------|------|-------------|
|
||||
| `feature` | + | New feature or capability |
|
||||
| `fix` | ~ | Bug fix |
|
||||
| `breaking` | ! | Breaking change (major version) |
|
||||
| `deprecation` | ? | Deprecation notice |
|
||||
|
||||
## Deprecation Severity Levels
|
||||
|
||||
| Severity | Description |
|
||||
|----------|-------------|
|
||||
| `info` | Informational notice, no immediate action required |
|
||||
| `warning` | Feature will be removed in future version, plan migration |
|
||||
| `critical` | Feature removed or will be removed imminently, immediate action required |
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Check for updates regularly** in CI to stay informed about new SDK versions
|
||||
2. **Review changelogs** before upgrading to understand new features and breaking changes
|
||||
3. **Monitor deprecations** to plan migrations before features are removed
|
||||
4. **Use `--check-only`** in automated pipelines to avoid unintended downloads
|
||||
5. **Filter by language** when working on specific platform integrations
|
||||
6. **Integrate with notifications** to alert teams about available updates
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
444
src/Cli/StellaOps.Cli/Configuration/CliProfile.cs
Normal file
444
src/Cli/StellaOps.Cli/Configuration/CliProfile.cs
Normal file
@@ -0,0 +1,444 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// CLI profile for storing named configurations.
|
||||
/// Per CLI-CORE-41-001, supports profiles/contexts for multi-environment workflows.
|
||||
/// </summary>
|
||||
public sealed class CliProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile name (e.g., "prod", "staging", "dev").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Backend URL for this profile.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Concelier URL for this profile.
|
||||
/// </summary>
|
||||
public string? ConcelierUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Authority URL for this profile.
|
||||
/// </summary>
|
||||
public string? AuthorityUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Client ID for this profile.
|
||||
/// </summary>
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default scope for this profile.
|
||||
/// </summary>
|
||||
public string? DefaultScope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy Studio scopes for this profile.
|
||||
/// CLI-POLICY-27-006: Supports the Policy Studio scope family.
|
||||
/// </summary>
|
||||
public PolicyStudioScopes? PolicyScopes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for this profile.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an air-gapped/offline profile.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Offline kit directory for this profile.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default output format for this profile.
|
||||
/// </summary>
|
||||
public string? DefaultOutputFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional profile-specific settings.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Settings { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when profile was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when profile was last modified.
|
||||
/// </summary>
|
||||
public DateTimeOffset ModifiedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Profile store configuration.
|
||||
/// </summary>
|
||||
public sealed class CliProfileStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Current active profile name.
|
||||
/// </summary>
|
||||
public string? CurrentProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All stored profiles.
|
||||
/// </summary>
|
||||
public Dictionary<string, CliProfile> Profiles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Default telemetry opt-in status.
|
||||
/// </summary>
|
||||
public bool? TelemetryEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages CLI profiles persistence.
|
||||
/// </summary>
|
||||
public sealed class CliProfileManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly string _profilesFilePath;
|
||||
private CliProfileStore? _store;
|
||||
|
||||
public CliProfileManager(string? profilesDirectory = null)
|
||||
{
|
||||
var directory = profilesDirectory ?? GetDefaultProfilesDirectory();
|
||||
_profilesFilePath = Path.Combine(directory, "profiles.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current profile store.
|
||||
/// </summary>
|
||||
public async Task<CliProfileStore> GetStoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store is not null)
|
||||
return _store;
|
||||
|
||||
_store = await LoadStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return _store;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active profile.
|
||||
/// </summary>
|
||||
public async Task<CliProfile?> GetCurrentProfileAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(store.CurrentProfile))
|
||||
return null;
|
||||
|
||||
store.Profiles.TryGetValue(store.CurrentProfile, out var profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by name.
|
||||
/// </summary>
|
||||
public async Task<CliProfile?> GetProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.Profiles.TryGetValue(name, out var profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists all profile names.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> ListProfilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return store.Profiles.Keys.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a profile as the current active profile.
|
||||
/// </summary>
|
||||
public async Task SetCurrentProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!store.Profiles.ContainsKey(name))
|
||||
throw new InvalidOperationException($"Profile '{name}' does not exist.");
|
||||
|
||||
store.CurrentProfile = name;
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves or updates a profile.
|
||||
/// </summary>
|
||||
public async Task SaveProfileAsync(CliProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.Profiles[profile.Name] = profile with { ModifiedAt = DateTimeOffset.UtcNow };
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile.
|
||||
/// </summary>
|
||||
public async Task<bool> RemoveProfileAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!store.Profiles.Remove(name))
|
||||
return false;
|
||||
|
||||
if (string.Equals(store.CurrentProfile, name, StringComparison.OrdinalIgnoreCase))
|
||||
store.CurrentProfile = null;
|
||||
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets or clears the telemetry opt-in status.
|
||||
/// </summary>
|
||||
public async Task SetTelemetryEnabledAsync(bool? enabled, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
store.TelemetryEnabled = enabled;
|
||||
await SaveStoreAsync(store, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the telemetry opt-in status.
|
||||
/// </summary>
|
||||
public async Task<bool?> GetTelemetryEnabledAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var store = await GetStoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
return store.TelemetryEnabled;
|
||||
}
|
||||
|
||||
private async Task<CliProfileStore> LoadStoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(_profilesFilePath))
|
||||
return new CliProfileStore();
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(_profilesFilePath);
|
||||
var store = await JsonSerializer.DeserializeAsync<CliProfileStore>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return store ?? new CliProfileStore();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new CliProfileStore();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveStoreAsync(CliProfileStore store, CancellationToken cancellationToken)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_profilesFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
await using var stream = File.Create(_profilesFilePath);
|
||||
await JsonSerializer.SerializeAsync(stream, store, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
_store = store;
|
||||
}
|
||||
|
||||
private static string GetDefaultProfilesDirectory()
|
||||
{
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(home))
|
||||
home = AppContext.BaseDirectory;
|
||||
|
||||
return Path.Combine(home, ".stellaops");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy Studio scope configuration.
|
||||
/// CLI-POLICY-27-006: Defines the Policy Studio scope family for CLI operations.
|
||||
/// </summary>
|
||||
public sealed class PolicyStudioScopes
|
||||
{
|
||||
/// <summary>
|
||||
/// Base scope for Policy Studio operations.
|
||||
/// Required for all policy commands.
|
||||
/// </summary>
|
||||
public const string PolicyRead = "stella.policy:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy write operations (create, update, delete).
|
||||
/// </summary>
|
||||
public const string PolicyWrite = "stella.policy:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy simulation operations.
|
||||
/// </summary>
|
||||
public const string PolicySimulate = "stella.policy:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy workflow operations (submit, review, approve, reject).
|
||||
/// </summary>
|
||||
public const string PolicyWorkflow = "stella.policy:workflow";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy publish/promote operations.
|
||||
/// </summary>
|
||||
public const string PolicyPublish = "stella.policy:publish";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for policy signing operations.
|
||||
/// </summary>
|
||||
public const string PolicySign = "stella.policy:sign";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for risk profile operations.
|
||||
/// </summary>
|
||||
public const string RiskRead = "stella.risk:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for risk simulation operations.
|
||||
/// </summary>
|
||||
public const string RiskSimulate = "stella.risk:simulate";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for reachability operations.
|
||||
/// </summary>
|
||||
public const string ReachabilityRead = "stella.reachability:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for reachability upload operations.
|
||||
/// </summary>
|
||||
public const string ReachabilityUpload = "stella.reachability:upload";
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy read operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyRead { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy write operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyWrite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy simulation is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicySimulate { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy workflow operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyWorkflow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy publish operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicyPublish { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether policy signing is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePolicySign { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether risk operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnableRisk { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether reachability operations are enabled.
|
||||
/// </summary>
|
||||
public bool EnableReachability { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the scope string for token requests.
|
||||
/// </summary>
|
||||
public string BuildScopeString()
|
||||
{
|
||||
var scopes = new List<string>();
|
||||
|
||||
if (EnablePolicyRead)
|
||||
scopes.Add(PolicyRead);
|
||||
if (EnablePolicyWrite)
|
||||
scopes.Add(PolicyWrite);
|
||||
if (EnablePolicySimulate)
|
||||
scopes.Add(PolicySimulate);
|
||||
if (EnablePolicyWorkflow)
|
||||
scopes.Add(PolicyWorkflow);
|
||||
if (EnablePolicyPublish)
|
||||
scopes.Add(PolicyPublish);
|
||||
if (EnablePolicySign)
|
||||
scopes.Add(PolicySign);
|
||||
if (EnableRisk)
|
||||
{
|
||||
scopes.Add(RiskRead);
|
||||
scopes.Add(RiskSimulate);
|
||||
}
|
||||
if (EnableReachability)
|
||||
{
|
||||
scopes.Add(ReachabilityRead);
|
||||
scopes.Add(ReachabilityUpload);
|
||||
}
|
||||
|
||||
return string.Join(" ", scopes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets guidance message for invalid_scope errors.
|
||||
/// </summary>
|
||||
public static string GetInvalidScopeGuidance(string? requiredScope)
|
||||
{
|
||||
var guidance = new System.Text.StringBuilder();
|
||||
guidance.AppendLine("The requested operation requires additional OAuth scopes.");
|
||||
guidance.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(requiredScope))
|
||||
{
|
||||
guidance.AppendLine($"Required scope: {requiredScope}");
|
||||
guidance.AppendLine();
|
||||
}
|
||||
|
||||
guidance.AppendLine("To resolve this issue:");
|
||||
guidance.AppendLine(" 1. Check your CLI profile configuration: stella profile show");
|
||||
guidance.AppendLine(" 2. Update your profile with required scopes: stella profile edit <name>");
|
||||
guidance.AppendLine(" 3. Request additional scopes from your administrator");
|
||||
guidance.AppendLine(" 4. Re-authenticate with the updated scopes: stella auth login");
|
||||
guidance.AppendLine();
|
||||
guidance.AppendLine("Available Policy Studio scopes:");
|
||||
guidance.AppendLine($" - {PolicyRead} (read policies)");
|
||||
guidance.AppendLine($" - {PolicyWrite} (create/update policies)");
|
||||
guidance.AppendLine($" - {PolicySimulate} (simulate policy changes)");
|
||||
guidance.AppendLine($" - {PolicyWorkflow} (submit/review/approve policies)");
|
||||
guidance.AppendLine($" - {PolicyPublish} (publish/promote policies)");
|
||||
guidance.AppendLine($" - {PolicySign} (sign policies)");
|
||||
guidance.AppendLine($" - {RiskRead} (read risk profiles)");
|
||||
guidance.AppendLine($" - {RiskSimulate} (simulate risk scoring)");
|
||||
guidance.AppendLine($" - {ReachabilityRead} (read reachability analyses)");
|
||||
guidance.AppendLine($" - {ReachabilityUpload} (upload call graphs)");
|
||||
|
||||
return guidance.ToString();
|
||||
}
|
||||
}
|
||||
140
src/Cli/StellaOps.Cli/Configuration/GlobalOptions.cs
Normal file
140
src/Cli/StellaOps.Cli/Configuration/GlobalOptions.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Output;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Global command-line options available to all commands.
|
||||
/// Per CLI-CORE-41-001, provides global flags for profile, output, verbosity, and telemetry.
|
||||
/// </summary>
|
||||
public sealed class GlobalOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile name to use for this invocation.
|
||||
/// </summary>
|
||||
public string? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format (json, yaml, table).
|
||||
/// </summary>
|
||||
public OutputFormat OutputFormat { get; set; } = OutputFormat.Table;
|
||||
|
||||
/// <summary>
|
||||
/// Verbose output (debug level logging).
|
||||
/// </summary>
|
||||
public bool Verbose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quiet mode (errors only).
|
||||
/// </summary>
|
||||
public bool Quiet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Disable colored output.
|
||||
/// </summary>
|
||||
public bool NoColor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override backend URL for this invocation.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Override tenant ID for this invocation.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dry run mode - show what would happen without executing.
|
||||
/// </summary>
|
||||
public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates System.CommandLine options for global flags.
|
||||
/// </summary>
|
||||
public static IEnumerable<Option> CreateGlobalOptions()
|
||||
{
|
||||
yield return new Option<string?>(
|
||||
aliases: ["--profile", "-p"],
|
||||
description: "Profile name to use for this invocation");
|
||||
|
||||
yield return new Option<OutputFormat>(
|
||||
aliases: ["--output", "-o"],
|
||||
getDefaultValue: () => OutputFormat.Table,
|
||||
description: "Output format (table, json, yaml)");
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--verbose", "-v"],
|
||||
description: "Enable verbose output");
|
||||
|
||||
yield return new Option<bool>(
|
||||
aliases: ["--quiet", "-q"],
|
||||
description: "Quiet mode - suppress non-error output");
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--no-color",
|
||||
description: "Disable colored output");
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--backend-url",
|
||||
description: "Override backend URL for this invocation");
|
||||
|
||||
yield return new Option<string?>(
|
||||
name: "--tenant-id",
|
||||
description: "Override tenant ID for this invocation");
|
||||
|
||||
yield return new Option<bool>(
|
||||
name: "--dry-run",
|
||||
description: "Show what would happen without executing");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses global options from invocation context.
|
||||
/// </summary>
|
||||
public static GlobalOptions FromInvocationContext(System.CommandLine.Invocation.InvocationContext context)
|
||||
{
|
||||
var options = new GlobalOptions();
|
||||
|
||||
var profileOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--profile"));
|
||||
if (profileOption is not null)
|
||||
options.Profile = context.ParseResult.GetValueForOption(profileOption) as string;
|
||||
|
||||
var outputOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--output"));
|
||||
if (outputOption is not null && context.ParseResult.GetValueForOption(outputOption) is OutputFormat format)
|
||||
options.OutputFormat = format;
|
||||
|
||||
var verboseOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--verbose"));
|
||||
if (verboseOption is not null && context.ParseResult.GetValueForOption(verboseOption) is bool verbose)
|
||||
options.Verbose = verbose;
|
||||
|
||||
var quietOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--quiet"));
|
||||
if (quietOption is not null && context.ParseResult.GetValueForOption(quietOption) is bool quiet)
|
||||
options.Quiet = quiet;
|
||||
|
||||
var noColorOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--no-color"));
|
||||
if (noColorOption is not null && context.ParseResult.GetValueForOption(noColorOption) is bool noColor)
|
||||
options.NoColor = noColor;
|
||||
|
||||
var backendOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--backend-url"));
|
||||
if (backendOption is not null)
|
||||
options.BackendUrl = context.ParseResult.GetValueForOption(backendOption) as string;
|
||||
|
||||
var tenantOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--tenant-id"));
|
||||
if (tenantOption is not null)
|
||||
options.TenantId = context.ParseResult.GetValueForOption(tenantOption) as string;
|
||||
|
||||
var dryRunOption = context.ParseResult.RootCommandResult.Command.Options
|
||||
.FirstOrDefault(o => o.HasAlias("--dry-run"));
|
||||
if (dryRunOption is not null && context.ParseResult.GetValueForOption(dryRunOption) is bool dryRun)
|
||||
options.DryRun = dryRun;
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal file
353
src/Cli/StellaOps.Cli/Output/CliError.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Structured CLI error with code, message, and optional details.
|
||||
/// Per CLI-CORE-41-001, provides error mapping for standardized API error envelopes.
|
||||
/// CLI-SDK-62-002: Enhanced to surface error.code and trace_id from API responses.
|
||||
/// </summary>
|
||||
public sealed record CliError(
|
||||
string Code,
|
||||
string Message,
|
||||
string? TraceId = null,
|
||||
string? Detail = null,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null,
|
||||
string? RequestId = null,
|
||||
string? HelpUrl = null,
|
||||
int? RetryAfter = null,
|
||||
string? Target = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Exit code to use when this error occurs.
|
||||
/// </summary>
|
||||
public int ExitCode => GetExitCode(Code);
|
||||
|
||||
/// <summary>
|
||||
/// Maps error code prefixes to exit codes.
|
||||
/// </summary>
|
||||
private static int GetExitCode(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return 1;
|
||||
|
||||
// Authentication/authorization errors
|
||||
if (code.StartsWith("AUTH_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
|
||||
return 2;
|
||||
|
||||
// Invalid scope errors
|
||||
if (code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
|
||||
return 3;
|
||||
|
||||
// Not found errors
|
||||
if (code.StartsWith("NOT_FOUND", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_NOT_FOUND", StringComparison.OrdinalIgnoreCase))
|
||||
return 4;
|
||||
|
||||
// Validation errors
|
||||
if (code.StartsWith("VALIDATION_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_VALIDATION_", StringComparison.OrdinalIgnoreCase))
|
||||
return 5;
|
||||
|
||||
// Rate limit errors
|
||||
if (code.StartsWith("RATE_LIMIT", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return 6;
|
||||
|
||||
// Air-gap errors
|
||||
if (code.StartsWith("AIRGAP_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_AIRGAP_", StringComparison.OrdinalIgnoreCase))
|
||||
return 7;
|
||||
|
||||
// AOC errors
|
||||
if (code.StartsWith("ERR_AOC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 8;
|
||||
|
||||
// Aggregation errors
|
||||
if (code.StartsWith("ERR_AGG_", StringComparison.OrdinalIgnoreCase))
|
||||
return 9;
|
||||
|
||||
// Forensic verification errors
|
||||
if (code.StartsWith("ERR_FORENSIC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 12;
|
||||
|
||||
// Determinism errors
|
||||
if (code.StartsWith("ERR_DETER_", StringComparison.OrdinalIgnoreCase))
|
||||
return 13;
|
||||
|
||||
// Observability errors
|
||||
if (code.StartsWith("ERR_OBS_", StringComparison.OrdinalIgnoreCase))
|
||||
return 14;
|
||||
|
||||
// Pack errors
|
||||
if (code.StartsWith("ERR_PACK_", StringComparison.OrdinalIgnoreCase))
|
||||
return 15;
|
||||
|
||||
// Exception governance errors
|
||||
if (code.StartsWith("ERR_EXC_", StringComparison.OrdinalIgnoreCase))
|
||||
return 16;
|
||||
|
||||
// Orchestrator errors
|
||||
if (code.StartsWith("ERR_ORCH_", StringComparison.OrdinalIgnoreCase))
|
||||
return 17;
|
||||
|
||||
// SBOM errors
|
||||
if (code.StartsWith("ERR_SBOM_", StringComparison.OrdinalIgnoreCase))
|
||||
return 18;
|
||||
|
||||
// Notify errors
|
||||
if (code.StartsWith("ERR_NOTIFY_", StringComparison.OrdinalIgnoreCase))
|
||||
return 19;
|
||||
|
||||
// Sbomer errors
|
||||
if (code.StartsWith("ERR_SBOMER_", StringComparison.OrdinalIgnoreCase))
|
||||
return 20;
|
||||
|
||||
// Network/connectivity errors
|
||||
if (code.StartsWith("NETWORK_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("ERR_NETWORK_", StringComparison.OrdinalIgnoreCase) ||
|
||||
code.StartsWith("CONNECTION_", StringComparison.OrdinalIgnoreCase))
|
||||
return 10;
|
||||
|
||||
// Timeout errors
|
||||
if (code.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase))
|
||||
return 11;
|
||||
|
||||
// Generic errors
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an exception.
|
||||
/// </summary>
|
||||
public static CliError FromException(Exception ex, string? traceId = null)
|
||||
{
|
||||
var code = ex switch
|
||||
{
|
||||
UnauthorizedAccessException => "ERR_AUTH_UNAUTHORIZED",
|
||||
TimeoutException => "ERR_TIMEOUT",
|
||||
OperationCanceledException => "ERR_CANCELLED",
|
||||
InvalidOperationException => "ERR_INVALID_OPERATION",
|
||||
ArgumentException => "ERR_VALIDATION_ARGUMENT",
|
||||
_ => "ERR_UNKNOWN"
|
||||
};
|
||||
|
||||
return new CliError(code, ex.Message, traceId, ex.InnerException?.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an HTTP status code.
|
||||
/// </summary>
|
||||
public static CliError FromHttpStatus(int statusCode, string? message = null, string? traceId = null)
|
||||
{
|
||||
var (code, defaultMessage) = statusCode switch
|
||||
{
|
||||
400 => ("ERR_VALIDATION_BAD_REQUEST", "Bad request"),
|
||||
401 => ("ERR_AUTH_UNAUTHORIZED", "Unauthorized"),
|
||||
403 => ("ERR_AUTH_FORBIDDEN", "Forbidden"),
|
||||
404 => ("ERR_NOT_FOUND", "Resource not found"),
|
||||
409 => ("ERR_CONFLICT", "Resource conflict"),
|
||||
422 => ("ERR_VALIDATION_UNPROCESSABLE", "Unprocessable entity"),
|
||||
429 => ("ERR_RATE_LIMIT", "Rate limit exceeded"),
|
||||
500 => ("ERR_SERVER_INTERNAL", "Internal server error"),
|
||||
502 => ("ERR_SERVER_BAD_GATEWAY", "Bad gateway"),
|
||||
503 => ("ERR_SERVER_UNAVAILABLE", "Service unavailable"),
|
||||
504 => ("ERR_SERVER_TIMEOUT", "Gateway timeout"),
|
||||
_ => ($"ERR_HTTP_{statusCode}", $"HTTP error {statusCode}")
|
||||
};
|
||||
|
||||
return new CliError(code, message ?? defaultMessage, traceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from a parsed API error.
|
||||
/// CLI-SDK-62-002: Surfaces standardized API error envelope fields.
|
||||
/// </summary>
|
||||
public static CliError FromParsedApiError(ParsedApiError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
|
||||
Dictionary<string, string>? metadata = null;
|
||||
if (error.Metadata is not null && error.Metadata.Count > 0)
|
||||
{
|
||||
metadata = error.Metadata
|
||||
.Where(kvp => kvp.Value is not null)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return new CliError(
|
||||
Code: error.Code,
|
||||
Message: error.Message,
|
||||
TraceId: error.TraceId,
|
||||
Detail: error.Detail,
|
||||
Metadata: metadata,
|
||||
RequestId: error.RequestId,
|
||||
HelpUrl: error.HelpUrl,
|
||||
RetryAfter: error.RetryAfter,
|
||||
Target: error.Target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error from an API error envelope.
|
||||
/// CLI-SDK-62-002: Direct conversion from envelope format.
|
||||
/// </summary>
|
||||
public static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var errorDetail = envelope.Error;
|
||||
var code = errorDetail?.Code ?? $"ERR_HTTP_{httpStatus}";
|
||||
var message = errorDetail?.Message ?? $"HTTP error {httpStatus}";
|
||||
|
||||
Dictionary<string, string>? metadata = null;
|
||||
if (errorDetail?.Metadata is not null && errorDetail.Metadata.Count > 0)
|
||||
{
|
||||
metadata = errorDetail.Metadata
|
||||
.Where(kvp => kvp.Value is not null)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "");
|
||||
}
|
||||
|
||||
return new CliError(
|
||||
Code: code,
|
||||
Message: message,
|
||||
TraceId: envelope.TraceId,
|
||||
Detail: errorDetail?.Detail,
|
||||
Metadata: metadata,
|
||||
RequestId: envelope.RequestId,
|
||||
HelpUrl: errorDetail?.HelpUrl,
|
||||
RetryAfter: errorDetail?.RetryAfter,
|
||||
Target: errorDetail?.Target);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known CLI error codes.
|
||||
/// </summary>
|
||||
public static class CliErrorCodes
|
||||
{
|
||||
public const string Unauthorized = "ERR_AUTH_UNAUTHORIZED";
|
||||
public const string Forbidden = "ERR_AUTH_FORBIDDEN";
|
||||
public const string InvalidScope = "ERR_AUTH_INVALID_SCOPE";
|
||||
public const string NotFound = "ERR_NOT_FOUND";
|
||||
public const string ValidationFailed = "ERR_VALIDATION_FAILED";
|
||||
public const string RateLimited = "ERR_RATE_LIMIT";
|
||||
public const string AirGapBlocked = "ERR_AIRGAP_EGRESS_BLOCKED";
|
||||
public const string AocViolation = "ERR_AOC_001";
|
||||
public const string NetworkError = "ERR_NETWORK_FAILED";
|
||||
public const string Timeout = "ERR_TIMEOUT";
|
||||
public const string Cancelled = "ERR_CANCELLED";
|
||||
public const string ConfigurationMissing = "ERR_CONFIG_MISSING";
|
||||
public const string ProfileNotFound = "ERR_PROFILE_NOT_FOUND";
|
||||
|
||||
// CLI-LNM-22-001: Aggregation error codes (exit code 9)
|
||||
public const string AggNoObservations = "ERR_AGG_NO_OBSERVATIONS";
|
||||
public const string AggConflictDetected = "ERR_AGG_CONFLICT_DETECTED";
|
||||
public const string AggLinksetEmpty = "ERR_AGG_LINKSET_EMPTY";
|
||||
public const string AggSourceMissing = "ERR_AGG_SOURCE_MISSING";
|
||||
public const string AggExportFailed = "ERR_AGG_EXPORT_FAILED";
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic verification error codes (exit code 12)
|
||||
public const string ForensicBundleNotFound = "ERR_FORENSIC_BUNDLE_NOT_FOUND";
|
||||
public const string ForensicBundleInvalid = "ERR_FORENSIC_BUNDLE_INVALID";
|
||||
public const string ForensicChecksumMismatch = "ERR_FORENSIC_CHECKSUM_MISMATCH";
|
||||
public const string ForensicSignatureInvalid = "ERR_FORENSIC_SIGNATURE_INVALID";
|
||||
public const string ForensicSignatureUntrusted = "ERR_FORENSIC_SIGNATURE_UNTRUSTED";
|
||||
public const string ForensicChainOfCustodyBroken = "ERR_FORENSIC_CHAIN_BROKEN";
|
||||
public const string ForensicTimelineInvalid = "ERR_FORENSIC_TIMELINE_INVALID";
|
||||
public const string ForensicTrustRootMissing = "ERR_FORENSIC_TRUST_ROOT_MISSING";
|
||||
|
||||
// CLI-DETER-70-003: Determinism error codes (exit code 13)
|
||||
public const string DeterminismDockerUnavailable = "ERR_DETER_DOCKER_UNAVAILABLE";
|
||||
public const string DeterminismNoImages = "ERR_DETER_NO_IMAGES";
|
||||
public const string DeterminismScannerMissing = "ERR_DETER_SCANNER_MISSING";
|
||||
public const string DeterminismThresholdFailed = "ERR_DETER_THRESHOLD_FAILED";
|
||||
public const string DeterminismRunFailed = "ERR_DETER_RUN_FAILED";
|
||||
public const string DeterminismManifestInvalid = "ERR_DETER_MANIFEST_INVALID";
|
||||
|
||||
// CLI-OBS-51-001: Observability error codes (exit code 14)
|
||||
public const string ObsConnectionFailed = "ERR_OBS_CONNECTION_FAILED";
|
||||
public const string ObsServiceUnavailable = "ERR_OBS_SERVICE_UNAVAILABLE";
|
||||
public const string ObsNoData = "ERR_OBS_NO_DATA";
|
||||
public const string ObsInvalidFilter = "ERR_OBS_INVALID_FILTER";
|
||||
public const string ObsOfflineViolation = "ERR_OBS_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-PACKS-42-001: Pack error codes (exit code 15)
|
||||
public const string PackNotFound = "ERR_PACK_NOT_FOUND";
|
||||
public const string PackValidationFailed = "ERR_PACK_VALIDATION_FAILED";
|
||||
public const string PackPlanFailed = "ERR_PACK_PLAN_FAILED";
|
||||
public const string PackRunFailed = "ERR_PACK_RUN_FAILED";
|
||||
public const string PackPushFailed = "ERR_PACK_PUSH_FAILED";
|
||||
public const string PackPullFailed = "ERR_PACK_PULL_FAILED";
|
||||
public const string PackVerifyFailed = "ERR_PACK_VERIFY_FAILED";
|
||||
public const string PackSignatureInvalid = "ERR_PACK_SIGNATURE_INVALID";
|
||||
public const string PackApprovalRequired = "ERR_PACK_APPROVAL_REQUIRED";
|
||||
public const string PackOfflineViolation = "ERR_PACK_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-EXC-25-001: Exception governance error codes (exit code 16)
|
||||
public const string ExcNotFound = "ERR_EXC_NOT_FOUND";
|
||||
public const string ExcValidationFailed = "ERR_EXC_VALIDATION_FAILED";
|
||||
public const string ExcCreateFailed = "ERR_EXC_CREATE_FAILED";
|
||||
public const string ExcPromoteFailed = "ERR_EXC_PROMOTE_FAILED";
|
||||
public const string ExcRevokeFailed = "ERR_EXC_REVOKE_FAILED";
|
||||
public const string ExcImportFailed = "ERR_EXC_IMPORT_FAILED";
|
||||
public const string ExcExportFailed = "ERR_EXC_EXPORT_FAILED";
|
||||
public const string ExcApprovalRequired = "ERR_EXC_APPROVAL_REQUIRED";
|
||||
public const string ExcExpired = "ERR_EXC_EXPIRED";
|
||||
public const string ExcConflict = "ERR_EXC_CONFLICT";
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator error codes (exit code 17)
|
||||
public const string OrchSourceNotFound = "ERR_ORCH_SOURCE_NOT_FOUND";
|
||||
public const string OrchSourcePaused = "ERR_ORCH_SOURCE_PAUSED";
|
||||
public const string OrchSourceThrottled = "ERR_ORCH_SOURCE_THROTTLED";
|
||||
public const string OrchTestFailed = "ERR_ORCH_TEST_FAILED";
|
||||
public const string OrchQuotaExceeded = "ERR_ORCH_QUOTA_EXCEEDED";
|
||||
public const string OrchConnectionFailed = "ERR_ORCH_CONNECTION_FAILED";
|
||||
|
||||
// CLI-PARITY-41-001: SBOM error codes (exit code 18)
|
||||
public const string SbomNotFound = "ERR_SBOM_NOT_FOUND";
|
||||
public const string SbomConnectionFailed = "ERR_SBOM_CONNECTION_FAILED";
|
||||
public const string SbomExportFailed = "ERR_SBOM_EXPORT_FAILED";
|
||||
public const string SbomCompareFailed = "ERR_SBOM_COMPARE_FAILED";
|
||||
public const string SbomInvalidFormat = "ERR_SBOM_INVALID_FORMAT";
|
||||
public const string SbomOfflineViolation = "ERR_SBOM_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-PARITY-41-002: Notify error codes (exit code 19)
|
||||
public const string NotifyChannelNotFound = "ERR_NOTIFY_CHANNEL_NOT_FOUND";
|
||||
public const string NotifyDeliveryNotFound = "ERR_NOTIFY_DELIVERY_NOT_FOUND";
|
||||
public const string NotifyConnectionFailed = "ERR_NOTIFY_CONNECTION_FAILED";
|
||||
public const string NotifySendFailed = "ERR_NOTIFY_SEND_FAILED";
|
||||
public const string NotifyTestFailed = "ERR_NOTIFY_TEST_FAILED";
|
||||
public const string NotifyRetryFailed = "ERR_NOTIFY_RETRY_FAILED";
|
||||
public const string NotifyOfflineViolation = "ERR_NOTIFY_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer error codes (exit code 20)
|
||||
public const string SbomerLayerNotFound = "ERR_SBOMER_LAYER_NOT_FOUND";
|
||||
public const string SbomerCompositionNotFound = "ERR_SBOMER_COMPOSITION_NOT_FOUND";
|
||||
public const string SbomerDsseInvalid = "ERR_SBOMER_DSSE_INVALID";
|
||||
public const string SbomerContentHashMismatch = "ERR_SBOMER_CONTENT_HASH_MISMATCH";
|
||||
public const string SbomerMerkleProofInvalid = "ERR_SBOMER_MERKLE_PROOF_INVALID";
|
||||
public const string SbomerComposeFailed = "ERR_SBOMER_COMPOSE_FAILED";
|
||||
public const string SbomerVerifyFailed = "ERR_SBOMER_VERIFY_FAILED";
|
||||
public const string SbomerNonDeterministic = "ERR_SBOMER_NON_DETERMINISTIC";
|
||||
public const string SbomerOfflineViolation = "ERR_SBOMER_OFFLINE_VIOLATION";
|
||||
|
||||
// CLI-POLICY-27-006: Policy Studio scope error codes (exit code 3)
|
||||
public const string PolicyStudioScopeRequired = "ERR_SCOPE_POLICY_STUDIO_REQUIRED";
|
||||
public const string PolicyStudioScopeInvalid = "ERR_SCOPE_POLICY_STUDIO_INVALID";
|
||||
public const string PolicyStudioScopeWorkflowRequired = "ERR_SCOPE_POLICY_WORKFLOW_REQUIRED";
|
||||
public const string PolicyStudioScopePublishRequired = "ERR_SCOPE_POLICY_PUBLISH_REQUIRED";
|
||||
public const string PolicyStudioScopeSignRequired = "ERR_SCOPE_POLICY_SIGN_REQUIRED";
|
||||
|
||||
// CLI-RISK-66-001: Risk scope error codes (exit code 3)
|
||||
public const string RiskScopeRequired = "ERR_SCOPE_RISK_REQUIRED";
|
||||
public const string RiskScopeProfileRequired = "ERR_SCOPE_RISK_PROFILE_REQUIRED";
|
||||
public const string RiskScopeSimulateRequired = "ERR_SCOPE_RISK_SIMULATE_REQUIRED";
|
||||
|
||||
// CLI-SIG-26-001: Reachability scope error codes (exit code 3)
|
||||
public const string ReachabilityScopeRequired = "ERR_SCOPE_REACHABILITY_REQUIRED";
|
||||
public const string ReachabilityScopeUploadRequired = "ERR_SCOPE_REACHABILITY_UPLOAD_REQUIRED";
|
||||
}
|
||||
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal file
211
src/Cli/StellaOps.Cli/Output/CliErrorRenderer.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for rendering CLI errors consistently.
|
||||
/// CLI-SDK-62-002: Provides standardized error output with error.code and trace_id.
|
||||
/// </summary>
|
||||
internal static class CliErrorRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error to the console.
|
||||
/// </summary>
|
||||
public static void Render(CliError error, bool verbose = false, bool asJson = false)
|
||||
{
|
||||
if (asJson)
|
||||
{
|
||||
RenderJson(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderConsole(error, verbose);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error as JSON.
|
||||
/// </summary>
|
||||
public static void RenderJson(CliError error)
|
||||
{
|
||||
var output = new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = error.Code,
|
||||
message = error.Message,
|
||||
detail = error.Detail,
|
||||
target = error.Target,
|
||||
help_url = error.HelpUrl,
|
||||
retry_after = error.RetryAfter,
|
||||
metadata = error.Metadata
|
||||
},
|
||||
trace_id = error.TraceId,
|
||||
request_id = error.RequestId,
|
||||
exit_code = error.ExitCode
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(output, JsonOptions);
|
||||
AnsiConsole.WriteLine(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error to the console with formatting.
|
||||
/// </summary>
|
||||
public static void RenderConsole(CliError error, bool verbose = false)
|
||||
{
|
||||
// Main error message
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}");
|
||||
|
||||
// Error code
|
||||
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(error.Code)}");
|
||||
|
||||
// Detail (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.Detail))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Detail:[/] {Markup.Escape(error.Detail)}");
|
||||
}
|
||||
|
||||
// Target (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.Target))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Target:[/] {Markup.Escape(error.Target)}");
|
||||
}
|
||||
|
||||
// Help URL (if present)
|
||||
if (!string.IsNullOrWhiteSpace(error.HelpUrl))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Help:[/] [link]{Markup.Escape(error.HelpUrl)}[/]");
|
||||
}
|
||||
|
||||
// Retry-after (if present)
|
||||
if (error.RetryAfter.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Retry after:[/] {error.RetryAfter} seconds");
|
||||
}
|
||||
|
||||
// Trace/Request IDs (shown in verbose mode or always for debugging)
|
||||
if (verbose || !string.IsNullOrWhiteSpace(error.TraceId))
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
if (!string.IsNullOrWhiteSpace(error.TraceId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Trace ID:[/] {Markup.Escape(error.TraceId)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(error.RequestId))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Request ID:[/] {Markup.Escape(error.RequestId)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata (shown in verbose mode)
|
||||
if (verbose && error.Metadata is not null && error.Metadata.Count > 0)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[grey]Metadata:[/]");
|
||||
foreach (var (key, value) in error.Metadata)
|
||||
{
|
||||
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(key)}:[/] {Markup.Escape(value)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a simple error message.
|
||||
/// </summary>
|
||||
public static void RenderSimple(string message, string? code = null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}");
|
||||
if (!string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(code)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a warning message.
|
||||
/// </summary>
|
||||
public static void RenderWarning(string message)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(message)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders scope guidance for invalid_scope errors.
|
||||
/// </summary>
|
||||
public static void RenderScopeGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[yellow]The requested operation requires additional OAuth scopes.[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("To resolve this issue:");
|
||||
AnsiConsole.MarkupLine(" 1. Check your CLI profile configuration: [cyan]stella profile show[/]");
|
||||
AnsiConsole.MarkupLine(" 2. Update your profile with required scopes: [cyan]stella profile edit <name>[/]");
|
||||
AnsiConsole.MarkupLine(" 3. Request additional scopes from your administrator");
|
||||
AnsiConsole.MarkupLine(" 4. Re-authenticate with the updated scopes: [cyan]stella auth login[/]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders rate limit guidance.
|
||||
/// </summary>
|
||||
public static void RenderRateLimitGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.Contains("RATE_LIMIT", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[yellow]Rate limit exceeded.[/]");
|
||||
|
||||
if (error.RetryAfter.HasValue)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"Please wait [cyan]{error.RetryAfter}[/] seconds before retrying.");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine("Please wait before retrying your request.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders authentication guidance.
|
||||
/// </summary>
|
||||
public static void RenderAuthGuidance(CliError error)
|
||||
{
|
||||
if (!error.Code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (error.Code == CliErrorCodes.Unauthorized)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Authentication required.[/]");
|
||||
AnsiConsole.MarkupLine("Please authenticate using: [cyan]stella auth login[/]");
|
||||
}
|
||||
else if (error.Code == CliErrorCodes.Forbidden)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Access denied.[/]");
|
||||
AnsiConsole.MarkupLine("You do not have permission to perform this operation.");
|
||||
AnsiConsole.MarkupLine("Contact your administrator to request access.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders contextual guidance based on error code.
|
||||
/// </summary>
|
||||
public static void RenderGuidance(CliError error)
|
||||
{
|
||||
RenderScopeGuidance(error);
|
||||
RenderRateLimitGuidance(error);
|
||||
RenderAuthGuidance(error);
|
||||
}
|
||||
}
|
||||
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal file
98
src/Cli/StellaOps.Cli/Output/IOutputRenderer.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for rendering CLI output in multiple formats.
|
||||
/// Per CLI-CORE-41-001, supports json/yaml/table rendering.
|
||||
/// </summary>
|
||||
public interface IOutputRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current output format.
|
||||
/// </summary>
|
||||
OutputFormat Format { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Renders a single object to the output stream.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the object to render.</typeparam>
|
||||
/// <param name="value">The value to render.</param>
|
||||
/// <param name="writer">The text writer to output to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a collection as a table or list.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of items in the collection.</typeparam>
|
||||
/// <param name="items">The items to render.</param>
|
||||
/// <param name="writer">The text writer to output to.</param>
|
||||
/// <param name="columns">Optional column definitions for table format.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RenderTableAsync<T>(
|
||||
IEnumerable<T> items,
|
||||
TextWriter writer,
|
||||
IReadOnlyList<ColumnDefinition<T>>? columns = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a success message.
|
||||
/// </summary>
|
||||
Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders an error message.
|
||||
/// </summary>
|
||||
Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a warning message.
|
||||
/// </summary>
|
||||
Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Column definition for table rendering.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the row item.</typeparam>
|
||||
public sealed class ColumnDefinition<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Column header text.
|
||||
/// </summary>
|
||||
public required string Header { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Function to extract the column value from an item.
|
||||
/// </summary>
|
||||
public required Func<T, string?> ValueSelector { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional minimum width for the column.
|
||||
/// </summary>
|
||||
public int? MinWidth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum width for the column (truncates with ellipsis).
|
||||
/// </summary>
|
||||
public int? MaxWidth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Alignment for the column content.
|
||||
/// </summary>
|
||||
public ColumnAlignment Alignment { get; init; } = ColumnAlignment.Left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Column alignment for table rendering.
|
||||
/// </summary>
|
||||
public enum ColumnAlignment
|
||||
{
|
||||
Left,
|
||||
Right,
|
||||
Center
|
||||
}
|
||||
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal file
17
src/Cli/StellaOps.Cli/Output/OutputFormat.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Output format for CLI commands.
|
||||
/// Per CLI-CORE-41-001, supports json/yaml/table formats.
|
||||
/// </summary>
|
||||
public enum OutputFormat
|
||||
{
|
||||
/// <summary>Human-readable table format (default).</summary>
|
||||
Table,
|
||||
|
||||
/// <summary>JSON format for automation/scripting.</summary>
|
||||
Json,
|
||||
|
||||
/// <summary>YAML format for configuration/scripting.</summary>
|
||||
Yaml
|
||||
}
|
||||
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
396
src/Cli/StellaOps.Cli/Output/OutputRenderer.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IOutputRenderer"/>.
|
||||
/// Per CLI-CORE-41-001, renders output in json/yaml/table formats.
|
||||
/// </summary>
|
||||
public sealed class OutputRenderer : IOutputRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
public OutputFormat Format { get; }
|
||||
|
||||
public OutputRenderer(OutputFormat format = OutputFormat.Table)
|
||||
{
|
||||
Format = format;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderAsync<T>(T value, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var output = Format switch
|
||||
{
|
||||
OutputFormat.Json => RenderJson(value),
|
||||
OutputFormat.Yaml => RenderYaml(value),
|
||||
OutputFormat.Table => RenderObject(value),
|
||||
_ => RenderObject(value)
|
||||
};
|
||||
|
||||
await writer.WriteLineAsync(output.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderTableAsync<T>(
|
||||
IEnumerable<T> items,
|
||||
TextWriter writer,
|
||||
IReadOnlyList<ColumnDefinition<T>>? columns = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var itemList = items.ToList();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var json = RenderJson(itemList);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
var yaml = RenderYaml(itemList);
|
||||
await writer.WriteLineAsync(yaml.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Table format
|
||||
if (itemList.Count == 0)
|
||||
{
|
||||
await writer.WriteLineAsync("(no results)".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveColumns = columns ?? InferColumns<T>();
|
||||
var table = BuildTable(itemList, effectiveColumns);
|
||||
await writer.WriteLineAsync(table.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderSuccessAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "success", message };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
await writer.WriteLineAsync($"status: success\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync($"✓ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderErrorAsync(string message, TextWriter writer, string? errorCode = null, string? traceId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "error", error_code = errorCode, message, trace_id = traceId };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("status: error");
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
sb.AppendLine($"error_code: {errorCode}");
|
||||
sb.AppendLine($"message: {EscapeYamlString(message)}");
|
||||
if (!string.IsNullOrWhiteSpace(traceId))
|
||||
sb.AppendLine($"trace_id: {traceId}");
|
||||
await writer.WriteLineAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.Append("✗ ");
|
||||
if (!string.IsNullOrWhiteSpace(errorCode))
|
||||
output.Append($"[{errorCode}] ");
|
||||
output.Append(message);
|
||||
if (!string.IsNullOrWhiteSpace(traceId))
|
||||
output.Append($" (trace: {traceId})");
|
||||
|
||||
await writer.WriteLineAsync(output.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenderWarningAsync(string message, TextWriter writer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Format == OutputFormat.Json)
|
||||
{
|
||||
var obj = new { status = "warning", message };
|
||||
await writer.WriteLineAsync(RenderJson(obj).AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Format == OutputFormat.Yaml)
|
||||
{
|
||||
await writer.WriteLineAsync($"status: warning\nmessage: {EscapeYamlString(message)}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync($"⚠ {message}".AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string RenderJson<T>(T value)
|
||||
{
|
||||
return JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
private static string RenderYaml<T>(T value)
|
||||
{
|
||||
// Simple YAML rendering via JSON conversion for now
|
||||
// A full YAML library would be used in production
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return ConvertJsonElementToYaml(doc.RootElement, 0);
|
||||
}
|
||||
|
||||
private static string ConvertJsonElementToYaml(JsonElement element, int indent)
|
||||
{
|
||||
var indentStr = new string(' ', indent * 2);
|
||||
var sb = new StringBuilder();
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine($"{indentStr}{prop.Name}:");
|
||||
sb.Append(ConvertJsonElementToYaml(prop.Value, indent + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{indentStr}{prop.Name}: {FormatYamlValue(prop.Value)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.Object || item.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine($"{indentStr}-");
|
||||
sb.Append(ConvertJsonElementToYaml(item, indent + 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"{indentStr}- {FormatYamlValue(item)}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
sb.AppendLine($"{indentStr}{FormatYamlValue(element)}");
|
||||
break;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatYamlValue(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => EscapeYamlString(element.GetString() ?? ""),
|
||||
JsonValueKind.Number => element.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static string EscapeYamlString(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return "\"\"";
|
||||
|
||||
if (value.Contains('\n') || value.Contains(':') || value.Contains('#') ||
|
||||
value.StartsWith(' ') || value.EndsWith(' ') ||
|
||||
value.StartsWith('"') || value.StartsWith('\''))
|
||||
{
|
||||
return $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string RenderObject<T>(T value)
|
||||
{
|
||||
if (value is null)
|
||||
return "(null)";
|
||||
|
||||
var type = typeof(T);
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.ToList();
|
||||
|
||||
if (properties.Count == 0)
|
||||
return value.ToString() ?? "(empty)";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var maxNameLength = properties.Max(p => p.Name.Length);
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var propValue = prop.GetValue(value);
|
||||
var displayValue = propValue?.ToString() ?? "(null)";
|
||||
sb.AppendLine($"{prop.Name.PadRight(maxNameLength)} : {displayValue}");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ColumnDefinition<T>> InferColumns<T>()
|
||||
{
|
||||
var properties = typeof(T).GetProperties()
|
||||
.Where(p => p.CanRead && IsSimpleType(p.PropertyType))
|
||||
.Take(8) // Limit to 8 columns for readability
|
||||
.ToList();
|
||||
|
||||
return properties.Select(p => new ColumnDefinition<T>
|
||||
{
|
||||
Header = ToHeaderCase(p.Name),
|
||||
ValueSelector = item => p.GetValue(item)?.ToString()
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static bool IsSimpleType(Type type)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return underlying.IsPrimitive ||
|
||||
underlying == typeof(string) ||
|
||||
underlying == typeof(DateTime) ||
|
||||
underlying == typeof(DateTimeOffset) ||
|
||||
underlying == typeof(Guid) ||
|
||||
underlying == typeof(decimal) ||
|
||||
underlying.IsEnum;
|
||||
}
|
||||
|
||||
private static string ToHeaderCase(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return name;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
var c = name[i];
|
||||
if (i > 0 && char.IsUpper(c))
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
sb.Append(i == 0 ? char.ToUpperInvariant(c) : c);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildTable<T>(IReadOnlyList<T> items, IReadOnlyList<ColumnDefinition<T>> columns)
|
||||
{
|
||||
if (columns.Count == 0 || items.Count == 0)
|
||||
return "(no data)";
|
||||
|
||||
// Calculate column widths
|
||||
var widths = new int[columns.Count];
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
widths[i] = columns[i].Header.Length;
|
||||
if (columns[i].MinWidth.HasValue)
|
||||
widths[i] = Math.Max(widths[i], columns[i].MinWidth.Value);
|
||||
}
|
||||
|
||||
// Get all values and update widths
|
||||
var rows = new List<string[]>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var row = new string[columns.Count];
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
var value = columns[i].ValueSelector(item) ?? "";
|
||||
if (columns[i].MaxWidth.HasValue && value.Length > columns[i].MaxWidth.Value)
|
||||
{
|
||||
value = value[..(columns[i].MaxWidth.Value - 3)] + "...";
|
||||
}
|
||||
row[i] = value;
|
||||
widths[i] = Math.Max(widths[i], value.Length);
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
// Build output
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(PadColumn(columns[i].Header.ToUpperInvariant(), widths[i], columns[i].Alignment));
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Separator
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(new string('-', widths[i]));
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Rows
|
||||
foreach (var row in rows)
|
||||
{
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(" ");
|
||||
sb.Append(PadColumn(row[i], widths[i], columns[i].Alignment));
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string PadColumn(string value, int width, ColumnAlignment alignment)
|
||||
{
|
||||
return alignment switch
|
||||
{
|
||||
ColumnAlignment.Right => value.PadLeft(width),
|
||||
ColumnAlignment.Center => value.PadLeft((width + value.Length) / 2).PadRight(width),
|
||||
_ => value.PadRight(width)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,85 @@ internal static class Program
|
||||
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
|
||||
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
|
||||
|
||||
// CLI-FORENSICS-53-001: Forensic snapshot client
|
||||
services.AddHttpClient<IForensicSnapshotClient, ForensicSnapshotClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "forensic-api");
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic verifier (local only, no HTTP)
|
||||
services.AddSingleton<IForensicVerifier, ForensicVerifier>();
|
||||
|
||||
// CLI-FORENSICS-54-002: Attestation reader (local only, no HTTP)
|
||||
services.AddSingleton<IAttestationReader, AttestationReader>();
|
||||
|
||||
// CLI-LNM-22-002: VEX observations client
|
||||
services.AddHttpClient<IVexObservationsClient, VexObservationsClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(2);
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
|
||||
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
|
||||
{
|
||||
client.BaseAddress = backendUri;
|
||||
}
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "vex-api");
|
||||
|
||||
// CLI-PROMO-70-001: Promotion assembler (local, may call crane/cosign)
|
||||
services.AddHttpClient<IPromotionAssembler, PromotionAssembler>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
|
||||
// CLI-DETER-70-003: Determinism harness (local only, executes docker)
|
||||
services.AddSingleton<IDeterminismHarness, DeterminismHarness>();
|
||||
|
||||
// CLI-OBS-51-001: Observability client for health metrics
|
||||
services.AddHttpClient<IObservabilityClient, ObservabilityClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "observability-api");
|
||||
|
||||
// CLI-PACKS-42-001: Pack client for Task Pack operations
|
||||
services.AddHttpClient<IPackClient, PackClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(10); // Pack operations may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "packs-api");
|
||||
|
||||
// CLI-EXC-25-001: Exception client for exception governance operations
|
||||
services.AddHttpClient<IExceptionClient, ExceptionClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "exceptions-api");
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator client for source/job management
|
||||
services.AddHttpClient<IOrchestratorClient, OrchestratorClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "orchestrator-api");
|
||||
|
||||
// CLI-PARITY-41-001: SBOM client for SBOM explorer
|
||||
services.AddHttpClient<ISbomClient, SbomClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbom-api");
|
||||
|
||||
// CLI-PARITY-41-002: Notify client for notification management
|
||||
services.AddHttpClient<INotifyClient, NotifyClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "notify-api");
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer client for layer/compose operations
|
||||
services.AddHttpClient<ISbomerClient, SbomerClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(5); // Composition may take longer
|
||||
}).AddEgressPolicyGuard("stellaops-cli", "sbomer-api");
|
||||
|
||||
await using var serviceProvider = services.BuildServiceProvider();
|
||||
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup");
|
||||
|
||||
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
385
src/Cli/StellaOps.Cli/Services/AttestationReader.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reader for attestation files (DSSE envelopes with in-toto statements).
|
||||
/// Per CLI-FORENSICS-54-002.
|
||||
/// </summary>
|
||||
internal sealed class AttestationReader : IAttestationReader
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
private const string InTotoStatementType = "https://in-toto.io/Statement/v0.1";
|
||||
private const string InTotoStatementV1Type = "https://in-toto.io/Statement/v1";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<AttestationReader> _logger;
|
||||
private readonly IForensicVerifier _verifier;
|
||||
|
||||
public AttestationReader(ILogger<AttestationReader> logger, IForensicVerifier verifier)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
|
||||
}
|
||||
|
||||
public async Task<AttestationShowResult> ReadAttestationAsync(
|
||||
string filePath,
|
||||
AttestationShowOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_logger.LogDebug("Reading attestation from {FilePath}", filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Attestation file not found: {filePath}", filePath);
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AttestationEnvelope envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<AttestationEnvelope>(json, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid attestation JSON");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse attestation envelope from {FilePath}", filePath);
|
||||
throw new InvalidDataException($"Failed to parse attestation envelope: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid base64 payload: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
InTotoStatement statement;
|
||||
try
|
||||
{
|
||||
statement = JsonSerializer.Deserialize<InTotoStatement>(payloadJson, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid in-toto statement JSON");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse in-toto statement from payload");
|
||||
throw new InvalidDataException($"Failed to parse in-toto statement: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// Extract subjects
|
||||
var subjects = statement.Subject
|
||||
.Select(s => new AttestationSubjectInfo
|
||||
{
|
||||
Name = s.Name,
|
||||
DigestAlgorithm = s.Digest.Keys.FirstOrDefault() ?? "unknown",
|
||||
DigestValue = s.Digest.Values.FirstOrDefault() ?? string.Empty
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Extract signatures
|
||||
var signatures = new List<AttestationSignatureInfo>();
|
||||
var trustRoots = options.TrustRoots.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
|
||||
{
|
||||
var loadedRoots = await _verifier.LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
trustRoots.AddRange(loadedRoots);
|
||||
}
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
var sigInfo = new AttestationSignatureInfo
|
||||
{
|
||||
KeyId = sig.KeyId ?? "(no key id)",
|
||||
Algorithm = "unknown" // Would need certificate parsing for actual algorithm
|
||||
};
|
||||
|
||||
if (options.VerifySignatures && trustRoots.Count > 0)
|
||||
{
|
||||
var matchingRoot = trustRoots.FirstOrDefault(tr =>
|
||||
string.Equals(tr.KeyId, sig.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingRoot is not null)
|
||||
{
|
||||
var isValid = VerifySignature(envelope, sig, matchingRoot);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
sigInfo = sigInfo with
|
||||
{
|
||||
Algorithm = matchingRoot.Algorithm,
|
||||
IsValid = isValid,
|
||||
IsTrusted = isValid && timeValid,
|
||||
SignerInfo = new AttestationSignerInfo
|
||||
{
|
||||
Fingerprint = matchingRoot.Fingerprint,
|
||||
NotBefore = matchingRoot.NotBefore,
|
||||
NotAfter = matchingRoot.NotAfter
|
||||
},
|
||||
Reason = !isValid ? "Signature verification failed" :
|
||||
!timeValid ? "Key outside validity period" : null
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
sigInfo = sigInfo with
|
||||
{
|
||||
IsValid = null,
|
||||
IsTrusted = false,
|
||||
Reason = "No matching trust root found"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
signatures.Add(sigInfo);
|
||||
}
|
||||
|
||||
// Extract predicate summary
|
||||
var predicateSummary = ExtractPredicateSummary(statement);
|
||||
|
||||
// Build verification result
|
||||
AttestationVerificationResult? verificationResult = null;
|
||||
if (options.VerifySignatures)
|
||||
{
|
||||
var validCount = signatures.Count(s => s.IsValid == true);
|
||||
var trustedCount = signatures.Count(s => s.IsTrusted == true);
|
||||
var errors = signatures
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Reason))
|
||||
.Select(s => $"{s.KeyId}: {s.Reason}")
|
||||
.ToList();
|
||||
|
||||
verificationResult = new AttestationVerificationResult
|
||||
{
|
||||
IsValid = validCount > 0,
|
||||
SignatureCount = signatures.Count,
|
||||
ValidSignatures = validCount,
|
||||
TrustedSignatures = trustedCount,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
return new AttestationShowResult
|
||||
{
|
||||
FilePath = filePath,
|
||||
PayloadType = envelope.PayloadType,
|
||||
StatementType = statement.Type,
|
||||
PredicateType = statement.PredicateType,
|
||||
Subjects = subjects,
|
||||
Signatures = signatures,
|
||||
PredicateSummary = predicateSummary,
|
||||
VerificationResult = verificationResult
|
||||
};
|
||||
}
|
||||
|
||||
private static bool VerifySignature(
|
||||
AttestationEnvelope envelope,
|
||||
AttestationSignature sig,
|
||||
ForensicTrustRoot trustRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
var pae = BuildPreAuthEncoding(envelope.PayloadType, payloadBytes);
|
||||
var signatureBytes = Convert.FromBase64String(sig.Signature);
|
||||
var publicKeyBytes = Convert.FromBase64String(trustRoot.PublicKey);
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
|
||||
|
||||
return rsa.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildPreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
// DSSE PAE format: "DSSEv1" + len(payloadType) + payloadType + len(payload) + payload
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
// Write "DSSEv1" prefix length and value
|
||||
writer.Write((long)PaePrefix.Length);
|
||||
writer.Write(Encoding.UTF8.GetBytes(PaePrefix));
|
||||
|
||||
// Write payload type length and value
|
||||
writer.Write((long)payloadTypeBytes.Length);
|
||||
writer.Write(payloadTypeBytes);
|
||||
|
||||
// Write payload length and value
|
||||
writer.Write((long)payload.Length);
|
||||
writer.Write(payload);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static AttestationPredicateSummary? ExtractPredicateSummary(InTotoStatement statement)
|
||||
{
|
||||
if (statement.Predicate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = new AttestationPredicateSummary
|
||||
{
|
||||
Type = statement.PredicateType
|
||||
};
|
||||
|
||||
// Try to extract common fields from predicate
|
||||
if (statement.Predicate is JsonElement element)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
var materials = new List<AttestationMaterial>();
|
||||
|
||||
// Extract buildType (SLSA)
|
||||
if (element.TryGetProperty("buildType", out var buildTypeProp) &&
|
||||
buildTypeProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { BuildType = buildTypeProp.GetString() };
|
||||
}
|
||||
|
||||
// Extract builder (SLSA)
|
||||
if (element.TryGetProperty("builder", out var builderProp))
|
||||
{
|
||||
if (builderProp.TryGetProperty("id", out var builderIdProp) &&
|
||||
builderIdProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { Builder = builderIdProp.GetString() };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract invocation ID (SLSA)
|
||||
if (element.TryGetProperty("invocation", out var invocationProp))
|
||||
{
|
||||
if (invocationProp.TryGetProperty("configSource", out var configSourceProp) &&
|
||||
configSourceProp.TryGetProperty("digest", out var digestProp))
|
||||
{
|
||||
foreach (var d in digestProp.EnumerateObject())
|
||||
{
|
||||
metadata[$"invocation.digest.{d.Name}"] = d.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract materials
|
||||
if (element.TryGetProperty("materials", out var materialsProp) &&
|
||||
materialsProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var material in materialsProp.EnumerateArray())
|
||||
{
|
||||
var uri = string.Empty;
|
||||
var digest = new Dictionary<string, string>();
|
||||
|
||||
if (material.TryGetProperty("uri", out var uriProp) &&
|
||||
uriProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
uri = uriProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (material.TryGetProperty("digest", out var matDigestProp))
|
||||
{
|
||||
foreach (var d in matDigestProp.EnumerateObject())
|
||||
{
|
||||
digest[d.Name] = d.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
materials.Add(new AttestationMaterial { Uri = uri, Digest = digest });
|
||||
}
|
||||
|
||||
summary = summary with { Materials = materials };
|
||||
}
|
||||
|
||||
// Extract timestamp
|
||||
if (element.TryGetProperty("metadata", out var metaProp))
|
||||
{
|
||||
if (metaProp.TryGetProperty("buildStartedOn", out var startedProp) &&
|
||||
startedProp.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(startedProp.GetString(), out var started))
|
||||
{
|
||||
summary = summary with { Timestamp = started };
|
||||
}
|
||||
else if (metaProp.TryGetProperty("buildFinishedOn", out var finishedProp) &&
|
||||
finishedProp.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(finishedProp.GetString(), out var finished))
|
||||
{
|
||||
summary = summary with { Timestamp = finished };
|
||||
}
|
||||
|
||||
if (metaProp.TryGetProperty("invocationId", out var invIdProp) &&
|
||||
invIdProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary = summary with { InvocationId = invIdProp.GetString() };
|
||||
}
|
||||
}
|
||||
|
||||
// Extract VEX-specific fields
|
||||
if (statement.PredicateType.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (element.TryGetProperty("author", out var authorProp) &&
|
||||
authorProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
metadata["author"] = authorProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("timestamp", out var tsProp) &&
|
||||
tsProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(tsProp.GetString(), out var ts))
|
||||
{
|
||||
summary = summary with { Timestamp = ts };
|
||||
}
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("version", out var versionProp))
|
||||
{
|
||||
if (versionProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
metadata["version"] = versionProp.GetString() ?? string.Empty;
|
||||
}
|
||||
else if (versionProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
metadata["version"] = versionProp.GetInt32().ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.Count > 0)
|
||||
{
|
||||
summary = summary with { Metadata = metadata };
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,83 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
return result ?? new AdvisoryObservationsResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildLinksetRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to query linkset (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new AdvisoryLinksetResponse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// Per CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
public async Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/concelier/observations/{Uri.EscapeDataString(observationId)}?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get observation (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<AdvisoryLinksetObservation>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildRequestUri(AdvisoryObservationsQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/observations?tenant=");
|
||||
@@ -130,6 +207,71 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLinksetRequestUri(AdvisoryLinksetQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/concelier/linkset?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
AppendValues(builder, "observationId", query.ObservationIds);
|
||||
AppendValues(builder, "alias", query.Aliases);
|
||||
AppendValues(builder, "purl", query.Purls);
|
||||
AppendValues(builder, "cpe", query.Cpes);
|
||||
AppendValues(builder, "source", query.Sources);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Severity))
|
||||
{
|
||||
builder.Append("&severity=");
|
||||
builder.Append(Uri.EscapeDataString(query.Severity));
|
||||
}
|
||||
|
||||
if (query.KevOnly.HasValue)
|
||||
{
|
||||
builder.Append("&kevOnly=");
|
||||
builder.Append(query.KevOnly.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.HasFix.HasValue)
|
||||
{
|
||||
builder.Append("&hasFix=");
|
||||
builder.Append(query.HasFix.Value ? "true" : "false");
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append("&limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
builder.Append("&cursor=");
|
||||
builder.Append(Uri.EscapeDataString(query.Cursor));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
|
||||
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append('&');
|
||||
builder.Append(name);
|
||||
builder.Append('=');
|
||||
builder.Append(Uri.EscapeDataString(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
|
||||
|
||||
850
src/Cli/StellaOps.Cli/Services/DeterminismHarness.cs
Normal file
850
src/Cli/StellaOps.Cli/Services/DeterminismHarness.cs
Normal file
@@ -0,0 +1,850 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism harness for running scanner with frozen conditions.
|
||||
/// Per CLI-DETER-70-003.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismHarness : IDeterminismHarness
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly string[] ArtifactPatterns = new[]
|
||||
{
|
||||
"sbom.json", "sbom.spdx.json", "sbom.cdx.json",
|
||||
"vex.json", "findings.json", "scan.json",
|
||||
"layers.json", "metadata.json"
|
||||
};
|
||||
|
||||
private readonly ILogger<DeterminismHarness> _logger;
|
||||
|
||||
public DeterminismHarness(ILogger<DeterminismHarness> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<DeterminismRunResult> RunAsync(
|
||||
DeterminismRunRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
var imageResults = new List<DeterminismImageResult>();
|
||||
var failedImages = new List<string>();
|
||||
|
||||
_logger.LogDebug("Starting determinism harness with {ImageCount} images, {Runs} runs each",
|
||||
request.Images.Count, request.Runs);
|
||||
|
||||
// Validate prerequisites
|
||||
if (!await IsDockerAvailableAsync(cancellationToken))
|
||||
{
|
||||
errors.Add("Docker is not available or not running");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
if (request.Images.Count == 0)
|
||||
{
|
||||
errors.Add("No images specified for determinism testing");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Scanner))
|
||||
{
|
||||
errors.Add("Scanner image reference is required");
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
var outputDir = request.OutputDir ?? Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"stella-detscore-{DateTime.UtcNow:yyyyMMdd-HHmmss}");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
// Resolve SHAs
|
||||
var scannerSha = await ResolveImageDigestAsync(request.Scanner, cancellationToken) ?? "unknown";
|
||||
var policySha = await ComputeBundleShaAsync(request.PolicyBundle, cancellationToken) ?? "none";
|
||||
var feedsSha = await ComputeBundleShaAsync(request.FeedsBundle, cancellationToken) ?? "none";
|
||||
|
||||
var fixedClock = request.FixedClock ?? DateTimeOffset.UtcNow;
|
||||
|
||||
// Run determinism tests for each image
|
||||
foreach (var imageRef in request.Images)
|
||||
{
|
||||
_logger.LogInformation("Testing determinism for image: {Image}", imageRef);
|
||||
|
||||
try
|
||||
{
|
||||
var imageResult = await RunImageDeterminismAsync(
|
||||
imageRef,
|
||||
request,
|
||||
fixedClock,
|
||||
outputDir,
|
||||
cancellationToken);
|
||||
|
||||
imageResults.Add(imageResult);
|
||||
|
||||
if (imageResult.Score < request.ImageThreshold)
|
||||
{
|
||||
failedImages.Add(imageRef);
|
||||
warnings.Add($"Image {imageRef} score {imageResult.Score:P0} below threshold {request.ImageThreshold:P0}");
|
||||
}
|
||||
|
||||
if (imageResult.NonDeterministic.Count > 0)
|
||||
{
|
||||
warnings.Add($"Image {imageRef} has non-deterministic artifacts: {string.Join(", ", imageResult.NonDeterministic)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test determinism for image {Image}", imageRef);
|
||||
errors.Add($"Failed to test image {imageRef}: {ex.Message}");
|
||||
imageResults.Add(new DeterminismImageResult
|
||||
{
|
||||
Digest = imageRef,
|
||||
Runs = 0,
|
||||
Identical = 0,
|
||||
Score = 0,
|
||||
Notes = $"Error: {ex.Message}"
|
||||
});
|
||||
failedImages.Add(imageRef);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall score
|
||||
var overallScore = imageResults.Count > 0
|
||||
? imageResults.Average(r => r.Score)
|
||||
: 0;
|
||||
|
||||
var passedThreshold = overallScore >= request.OverallThreshold &&
|
||||
failedImages.Count == 0;
|
||||
|
||||
if (overallScore < request.OverallThreshold)
|
||||
{
|
||||
warnings.Add($"Overall score {overallScore:P0} below threshold {request.OverallThreshold:P0}");
|
||||
}
|
||||
|
||||
// Build manifest
|
||||
var manifest = new DeterminismManifest
|
||||
{
|
||||
Version = "1",
|
||||
Release = request.Release ?? $"local-{DateTime.UtcNow:yyyyMMdd}",
|
||||
Platform = request.Platform,
|
||||
PolicySha = policySha,
|
||||
FeedsSha = feedsSha,
|
||||
ScannerSha = scannerSha,
|
||||
Images = imageResults,
|
||||
OverallScore = overallScore,
|
||||
Thresholds = new DeterminismThresholds
|
||||
{
|
||||
ImageMin = request.ImageThreshold,
|
||||
OverallMin = request.OverallThreshold
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Execution = new DeterminismExecutionInfo
|
||||
{
|
||||
FixedClock = fixedClock,
|
||||
RngSeed = request.RngSeed,
|
||||
MaxConcurrency = request.MaxConcurrency,
|
||||
MemoryLimit = request.MemoryLimit,
|
||||
CpuSet = request.CpuSet,
|
||||
NetworkMode = "none"
|
||||
}
|
||||
};
|
||||
|
||||
// Write manifest
|
||||
var manifestPath = Path.Combine(outputDir, "determinism.json");
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
|
||||
_logger.LogInformation("Wrote determinism manifest to {Path}", manifestPath);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new DeterminismRunResult
|
||||
{
|
||||
Success = errors.Count == 0,
|
||||
Manifest = manifest,
|
||||
OutputPath = manifestPath,
|
||||
PassedThreshold = passedThreshold,
|
||||
FailedImages = failedImages,
|
||||
Errors = errors,
|
||||
Warnings = warnings,
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
public DeterminismVerificationResult VerifyManifest(
|
||||
DeterminismManifest manifest,
|
||||
double imageThreshold,
|
||||
double overallThreshold)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var failedImages = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var image in manifest.Images)
|
||||
{
|
||||
if (image.Score < imageThreshold)
|
||||
{
|
||||
failedImages.Add(image.Digest);
|
||||
}
|
||||
|
||||
if (image.NonDeterministic.Count > 0)
|
||||
{
|
||||
warnings.Add($"Image {image.Digest} has non-deterministic artifacts: {string.Join(", ", image.NonDeterministic)}");
|
||||
}
|
||||
}
|
||||
|
||||
var passed = manifest.OverallScore >= overallThreshold && failedImages.Count == 0;
|
||||
|
||||
if (manifest.OverallScore < overallThreshold)
|
||||
{
|
||||
warnings.Add($"Overall score {manifest.OverallScore:P0} below threshold {overallThreshold:P0}");
|
||||
}
|
||||
|
||||
return new DeterminismVerificationResult
|
||||
{
|
||||
Passed = passed,
|
||||
OverallScore = manifest.OverallScore,
|
||||
FailedImages = failedImages.ToArray(),
|
||||
Warnings = warnings.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<DeterminismImageResult> RunImageDeterminismAsync(
|
||||
string imageRef,
|
||||
DeterminismRunRequest request,
|
||||
DateTimeOffset fixedClock,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var imageDigest = await ResolveImageDigestAsync(imageRef, cancellationToken) ?? imageRef;
|
||||
var imageOutputDir = Path.Combine(outputDir, SanitizeForPath(imageDigest));
|
||||
Directory.CreateDirectory(imageOutputDir);
|
||||
|
||||
var runDetails = new List<DeterminismRunDetail>();
|
||||
Dictionary<string, string>? baselineHashes = null;
|
||||
var identicalCount = 0;
|
||||
var nonDeterministic = new HashSet<string>();
|
||||
|
||||
for (var runNum = 1; runNum <= request.Runs; runNum++)
|
||||
{
|
||||
_logger.LogDebug("Running determinism test {Run}/{Total} for {Image}",
|
||||
runNum, request.Runs, imageRef);
|
||||
|
||||
var runDir = Path.Combine(imageOutputDir, $"run_{runNum}");
|
||||
Directory.CreateDirectory(runDir);
|
||||
|
||||
var runSw = Stopwatch.StartNew();
|
||||
var exitCode = await RunScannerAsync(
|
||||
imageRef,
|
||||
request.Scanner,
|
||||
request.PolicyBundle,
|
||||
request.FeedsBundle,
|
||||
fixedClock,
|
||||
request.RngSeed,
|
||||
request.MaxConcurrency,
|
||||
request.MemoryLimit,
|
||||
request.CpuSet,
|
||||
runDir,
|
||||
cancellationToken);
|
||||
runSw.Stop();
|
||||
|
||||
// Compute hashes for artifacts
|
||||
var artifactHashes = await ComputeArtifactHashesAsync(runDir, cancellationToken);
|
||||
|
||||
// Compare with baseline
|
||||
var isIdentical = true;
|
||||
if (baselineHashes == null)
|
||||
{
|
||||
baselineHashes = artifactHashes;
|
||||
isIdentical = true; // First run is always "identical" (it's the baseline)
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (artifact, hash) in artifactHashes)
|
||||
{
|
||||
if (baselineHashes.TryGetValue(artifact, out var baselineHash))
|
||||
{
|
||||
if (hash != baselineHash)
|
||||
{
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
_logger.LogWarning("Non-deterministic artifact {Artifact} in run {Run}: {Hash} != {Baseline}",
|
||||
artifact, runNum, hash[..16], baselineHash[..16]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New artifact not in baseline
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing artifacts
|
||||
foreach (var artifact in baselineHashes.Keys)
|
||||
{
|
||||
if (!artifactHashes.ContainsKey(artifact))
|
||||
{
|
||||
isIdentical = false;
|
||||
nonDeterministic.Add(artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isIdentical)
|
||||
{
|
||||
identicalCount++;
|
||||
}
|
||||
|
||||
runDetails.Add(new DeterminismRunDetail
|
||||
{
|
||||
RunNumber = runNum,
|
||||
Identical = isIdentical,
|
||||
ArtifactHashes = artifactHashes,
|
||||
DurationMs = runSw.ElapsedMilliseconds,
|
||||
ExitCode = exitCode
|
||||
});
|
||||
}
|
||||
|
||||
var score = request.Runs > 0 ? (double)identicalCount / request.Runs : 0;
|
||||
|
||||
return new DeterminismImageResult
|
||||
{
|
||||
Digest = imageDigest,
|
||||
Runs = request.Runs,
|
||||
Identical = identicalCount,
|
||||
Score = score,
|
||||
ArtifactHashes = baselineHashes ?? new Dictionary<string, string>(),
|
||||
NonDeterministic = nonDeterministic.ToArray(),
|
||||
RunDetails = runDetails
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<int> RunScannerAsync(
|
||||
string imageRef,
|
||||
string scannerImage,
|
||||
string? policyBundle,
|
||||
string? feedsBundle,
|
||||
DateTimeOffset fixedClock,
|
||||
int rngSeed,
|
||||
int maxConcurrency,
|
||||
string memoryLimit,
|
||||
string cpuSet,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Build docker run command with determinism constraints
|
||||
var args = new StringBuilder();
|
||||
args.Append("run --rm ");
|
||||
args.Append($"--network=none ");
|
||||
args.Append($"--cpuset-cpus={cpuSet} ");
|
||||
args.Append($"--memory={memoryLimit} ");
|
||||
args.Append($"-e RNG_SEED={rngSeed} ");
|
||||
args.Append($"-e SCANNER_MAX_CONCURRENCY={maxConcurrency} ");
|
||||
args.Append($"-v \"{outputDir}:/output\" ");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyBundle) && File.Exists(policyBundle))
|
||||
{
|
||||
args.Append($"-v \"{Path.GetFullPath(policyBundle)}:/policy:ro\" ");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(feedsBundle) && File.Exists(feedsBundle))
|
||||
{
|
||||
args.Append($"-v \"{Path.GetFullPath(feedsBundle)}:/feeds:ro\" ");
|
||||
}
|
||||
|
||||
args.Append($"{scannerImage} ");
|
||||
args.Append($"scan --image {imageRef} ");
|
||||
args.Append($"--fixed-clock {fixedClock:yyyy-MM-ddTHH:mm:ssZ} ");
|
||||
args.Append("--output /output ");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = args.ToString(),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogDebug("Executing: docker {Args}", args);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Read output asynchronously
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(stderr))
|
||||
{
|
||||
_logger.LogDebug("Scanner stderr: {Stderr}", stderr);
|
||||
}
|
||||
|
||||
// Save stdout/stderr for diagnostics
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "stdout.log"),
|
||||
stdout,
|
||||
cancellationToken);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(outputDir, "stderr.log"),
|
||||
stderr,
|
||||
cancellationToken);
|
||||
|
||||
return process.ExitCode;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ComputeArtifactHashesAsync(
|
||||
string directory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hashes = new Dictionary<string, string>();
|
||||
|
||||
foreach (var pattern in ArtifactPatterns)
|
||||
{
|
||||
var filePath = Path.Combine(directory, pattern);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(filePath, cancellationToken);
|
||||
hashes[pattern] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for any .json files in output
|
||||
foreach (var file in Directory.GetFiles(directory, "*.json"))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
if (!hashes.ContainsKey(fileName))
|
||||
{
|
||||
var hash = await ComputeFileHashAsync(file, cancellationToken);
|
||||
hashes[fileName] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileHashAsync(string filePath, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task<string?> ComputeBundleShaAsync(string? bundlePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
return null;
|
||||
|
||||
if (!File.Exists(bundlePath))
|
||||
return null;
|
||||
|
||||
return await ComputeFileHashAsync(bundlePath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveImageDigestAsync(string imageRef, CancellationToken cancellationToken)
|
||||
{
|
||||
// If already contains digest, extract it
|
||||
if (imageRef.Contains("@sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var atIndex = imageRef.IndexOf('@');
|
||||
if (atIndex >= 0)
|
||||
{
|
||||
return imageRef[(atIndex + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
// Try using crane/docker to resolve
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = $"inspect --format='{{{{.RepoDigests}}}}' {imageRef}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode == 0 && stdout.Contains("sha256:"))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(stdout, @"sha256:[a-f0-9]{64}");
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors, return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<bool> IsDockerAvailableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = "version --format '{{.Server.Version}}'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeForPath(string input)
|
||||
{
|
||||
// Replace invalid path characters
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var result = new StringBuilder(input);
|
||||
foreach (var c in invalid)
|
||||
{
|
||||
result.Replace(c, '_');
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
// CLI-DETER-70-004: Generate report implementation
|
||||
public async Task<DeterminismReportResult> GenerateReportAsync(
|
||||
DeterminismReportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
var manifests = new List<(string path, DeterminismManifest manifest)>();
|
||||
|
||||
_logger.LogDebug("Generating determinism report from {Count} manifests", request.ManifestPaths.Count);
|
||||
|
||||
if (request.ManifestPaths.Count == 0)
|
||||
{
|
||||
errors.Add("No manifest paths provided");
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
// Load all manifests
|
||||
foreach (var path in request.ManifestPaths)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
warnings.Add($"Manifest not found: {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<DeterminismManifest>(json, SerializerOptions);
|
||||
if (manifest != null)
|
||||
{
|
||||
manifests.Add((path, manifest));
|
||||
}
|
||||
else
|
||||
{
|
||||
warnings.Add($"Failed to parse manifest: {path}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"Error reading manifest {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (manifests.Count == 0)
|
||||
{
|
||||
errors.Add("No valid manifests found");
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
// Build release entries
|
||||
var releases = manifests.Select(m => new DeterminismReleaseEntry
|
||||
{
|
||||
Release = m.manifest.Release,
|
||||
Platform = m.manifest.Platform,
|
||||
OverallScore = m.manifest.OverallScore,
|
||||
Passed = m.manifest.OverallScore >= m.manifest.Thresholds.OverallMin,
|
||||
ImageCount = m.manifest.Images.Count,
|
||||
GeneratedAt = m.manifest.GeneratedAt,
|
||||
ScannerSha = m.manifest.ScannerSha,
|
||||
ManifestPath = m.path
|
||||
}).OrderByDescending(r => r.GeneratedAt).ToList();
|
||||
|
||||
// Build image matrix
|
||||
var imageScores = new Dictionary<string, Dictionary<string, double>>();
|
||||
var imageNonDet = new Dictionary<string, HashSet<string>>();
|
||||
|
||||
foreach (var (path, manifest) in manifests)
|
||||
{
|
||||
foreach (var img in manifest.Images)
|
||||
{
|
||||
if (!imageScores.ContainsKey(img.Digest))
|
||||
{
|
||||
imageScores[img.Digest] = new Dictionary<string, double>();
|
||||
imageNonDet[img.Digest] = new HashSet<string>();
|
||||
}
|
||||
|
||||
imageScores[img.Digest][manifest.Release] = img.Score;
|
||||
|
||||
foreach (var nd in img.NonDeterministic)
|
||||
{
|
||||
imageNonDet[img.Digest].Add(nd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var imageMatrix = imageScores.Select(kvp => new DeterminismImageMatrixEntry
|
||||
{
|
||||
ImageDigest = kvp.Key,
|
||||
Scores = kvp.Value,
|
||||
AverageScore = kvp.Value.Values.Average(),
|
||||
NonDeterministicArtifacts = imageNonDet[kvp.Key].ToArray()
|
||||
}).OrderBy(e => e.AverageScore).ToList();
|
||||
|
||||
// Compute summary
|
||||
var allScores = manifests.Select(m => m.manifest.OverallScore).ToList();
|
||||
var allNonDet = manifests
|
||||
.SelectMany(m => m.manifest.Images)
|
||||
.SelectMany(i => i.NonDeterministic)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var summary = new DeterminismReportSummary
|
||||
{
|
||||
TotalReleases = manifests.Count,
|
||||
TotalImages = imageMatrix.Count,
|
||||
AverageScore = allScores.Count > 0 ? allScores.Average() : 0,
|
||||
MinScore = allScores.Count > 0 ? allScores.Min() : 0,
|
||||
MaxScore = allScores.Count > 0 ? allScores.Max() : 0,
|
||||
PassedCount = releases.Count(r => r.Passed),
|
||||
FailedCount = releases.Count(r => !r.Passed),
|
||||
NonDeterministicArtifacts = allNonDet
|
||||
};
|
||||
|
||||
var report = new DeterminismReport
|
||||
{
|
||||
Title = request.Title ?? "Determinism Score Report",
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary = summary,
|
||||
Releases = releases,
|
||||
ImageMatrix = imageMatrix
|
||||
};
|
||||
|
||||
// Write output
|
||||
string? outputPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.OutputPath))
|
||||
{
|
||||
outputPath = request.OutputPath;
|
||||
var content = request.Format.ToLowerInvariant() switch
|
||||
{
|
||||
"json" => JsonSerializer.Serialize(report, SerializerOptions),
|
||||
"csv" => GenerateCsvReport(report),
|
||||
_ => GenerateMarkdownReport(report, request.IncludeDetails)
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Wrote determinism report to {Path}", outputPath);
|
||||
}
|
||||
|
||||
return new DeterminismReportResult
|
||||
{
|
||||
Success = true,
|
||||
Report = report,
|
||||
OutputPath = outputPath,
|
||||
Format = request.Format,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateMarkdownReport(DeterminismReport report, bool includeDetails)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine($"# {report.Title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Generated: {report.GeneratedAt:u}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Summary
|
||||
sb.AppendLine("## Summary");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"| Metric | Value |");
|
||||
sb.AppendLine($"|--------|-------|");
|
||||
sb.AppendLine($"| Total Releases | {report.Summary.TotalReleases} |");
|
||||
sb.AppendLine($"| Total Images | {report.Summary.TotalImages} |");
|
||||
sb.AppendLine($"| Average Score | {report.Summary.AverageScore:P1} |");
|
||||
sb.AppendLine($"| Min Score | {report.Summary.MinScore:P1} |");
|
||||
sb.AppendLine($"| Max Score | {report.Summary.MaxScore:P1} |");
|
||||
sb.AppendLine($"| Passed | {report.Summary.PassedCount} |");
|
||||
sb.AppendLine($"| Failed | {report.Summary.FailedCount} |");
|
||||
sb.AppendLine();
|
||||
|
||||
if (report.Summary.NonDeterministicArtifacts.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Non-Deterministic Artifacts");
|
||||
sb.AppendLine();
|
||||
foreach (var artifact in report.Summary.NonDeterministicArtifacts)
|
||||
{
|
||||
sb.AppendLine($"- `{artifact}`");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Releases table
|
||||
sb.AppendLine("## Releases");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Release | Platform | Score | Status | Images | Generated |");
|
||||
sb.AppendLine("|---------|----------|-------|--------|--------|-----------|");
|
||||
|
||||
foreach (var release in report.Releases)
|
||||
{
|
||||
var status = release.Passed ? "✅ PASS" : "❌ FAIL";
|
||||
sb.AppendLine($"| {release.Release} | {release.Platform} | {release.OverallScore:P1} | {status} | {release.ImageCount} | {release.GeneratedAt:yyyy-MM-dd} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Image matrix
|
||||
if (includeDetails && report.ImageMatrix.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Per-Image Matrix");
|
||||
sb.AppendLine();
|
||||
|
||||
var releaseNames = report.Releases.Select(r => r.Release).ToList();
|
||||
|
||||
// Header
|
||||
sb.Append("| Image |");
|
||||
foreach (var rel in releaseNames)
|
||||
{
|
||||
sb.Append($" {rel} |");
|
||||
}
|
||||
sb.AppendLine(" Avg |");
|
||||
|
||||
// Separator
|
||||
sb.Append("|-------|");
|
||||
foreach (var _ in releaseNames)
|
||||
{
|
||||
sb.Append("------|");
|
||||
}
|
||||
sb.AppendLine("-----|");
|
||||
|
||||
// Rows
|
||||
foreach (var img in report.ImageMatrix)
|
||||
{
|
||||
var digest = img.ImageDigest.Length > 16 ? img.ImageDigest[..16] + "..." : img.ImageDigest;
|
||||
sb.Append($"| `{digest}` |");
|
||||
|
||||
foreach (var rel in releaseNames)
|
||||
{
|
||||
if (img.Scores.TryGetValue(rel, out var score))
|
||||
{
|
||||
sb.Append($" {score:P0} |");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" - |");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($" {img.AverageScore:P0} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateCsvReport(DeterminismReport report)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine("Release,Platform,Score,Passed,ImageCount,GeneratedAt,ScannerSha");
|
||||
|
||||
// Rows
|
||||
foreach (var release in report.Releases)
|
||||
{
|
||||
sb.AppendLine($"{release.Release},{release.Platform},{release.OverallScore:F4},{release.Passed},{release.ImageCount},{release.GeneratedAt:o},{release.ScannerSha}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
596
src/Cli/StellaOps.Cli/Services/ExceptionClient.cs
Normal file
596
src/Cli/StellaOps.Cli/Services/ExceptionClient.cs
Normal file
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
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.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for exception governance API operations.
|
||||
/// Per CLI-EXC-25-001.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionClient : IExceptionClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<ExceptionClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ExceptionClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ExceptionClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionListResponse> ListAsync(
|
||||
ExceptionListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing exceptions");
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing exceptions");
|
||||
return new ExceptionListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionInstance?> GetAsync(
|
||||
string exceptionId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/exceptions/{Uri.EscapeDataString(exceptionId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<ExceptionInstance>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting exception");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting exception");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> CreateAsync(
|
||||
ExceptionCreateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to create exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while creating exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while creating exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> PromoteAsync(
|
||||
ExceptionPromoteRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/promote")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.approve", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to promote exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while promoting exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while promoting exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionOperationResult> RevokeAsync(
|
||||
ExceptionRevokeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/revoke")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to revoke exception (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionOperationResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while revoking exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while revoking exception");
|
||||
return new ExceptionOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExceptionImportResult> ImportAsync(
|
||||
ExceptionImportRequest request,
|
||||
Stream ndjsonStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(ndjsonStream);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var streamContent = new StreamContent(ndjsonStream);
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson");
|
||||
content.Add(streamContent, "file", "exceptions.ndjson");
|
||||
content.Add(new StringContent(request.Tenant), "tenant");
|
||||
content.Add(new StringContent(request.Stage.ToString().ToLowerInvariant()), "stage");
|
||||
if (!string.IsNullOrWhiteSpace(request.Source))
|
||||
{
|
||||
content.Add(new StringContent(request.Source), "source");
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions/import")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to import exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}" }]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ExceptionImportResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = "Empty response" }] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while importing exceptions");
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = $"Connection error: {ex.Message}" }]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while importing exceptions");
|
||||
return new ExceptionImportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [new ExceptionImportError { Line = 0, Message = "Request timed out" }]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
|
||||
ExceptionExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.Statuses is { Count: > 0 })
|
||||
{
|
||||
foreach (var status in request.Statuses)
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
||||
}
|
||||
}
|
||||
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
|
||||
queryParams.Add($"includeManifest={request.IncludeManifest.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/exceptions/export{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to export exceptions (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
|
||||
// Parse manifest from header if present
|
||||
ExceptionExportManifest? manifest = null;
|
||||
if (response.Headers.TryGetValues("X-Export-Manifest", out var manifestValues))
|
||||
{
|
||||
var manifestJson = string.Join("", manifestValues);
|
||||
if (!string.IsNullOrWhiteSpace(manifestJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<ExceptionExportManifest>(manifestJson, SerializerOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore parse errors for optional header
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (contentStream, manifest);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while exporting exceptions");
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while exporting exceptions");
|
||||
return (Stream.Null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildListUri(ExceptionListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Vuln))
|
||||
{
|
||||
queryParams.Add($"vuln={Uri.EscapeDataString(request.Vuln)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ScopeType))
|
||||
{
|
||||
queryParams.Add($"scopeType={Uri.EscapeDataString(request.ScopeType)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ScopeValue))
|
||||
{
|
||||
queryParams.Add($"scopeValue={Uri.EscapeDataString(request.ScopeValue)}");
|
||||
}
|
||||
if (request.Statuses is { Count: > 0 })
|
||||
{
|
||||
foreach (var status in request.Statuses)
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(status)}");
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Owner))
|
||||
{
|
||||
queryParams.Add($"owner={Uri.EscapeDataString(request.Owner)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EffectType))
|
||||
{
|
||||
queryParams.Add($"effectType={Uri.EscapeDataString(request.EffectType)}");
|
||||
}
|
||||
if (request.ExpiringBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.IncludeExpired)
|
||||
{
|
||||
queryParams.Add("includeExpired=true");
|
||||
}
|
||||
queryParams.Add($"pageSize={request.PageSize}");
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
{
|
||||
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/exceptions{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
372
src/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs
Normal file
372
src/Cli/StellaOps.Cli/Services/ForensicSnapshotClient.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for forensic snapshot and evidence locker APIs.
|
||||
/// Per CLI-FORENSICS-53-001.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotClient : IForensicSnapshotClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<ForensicSnapshotClient> _logger;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly object _tokenSync = new();
|
||||
|
||||
private string? _cachedAccessToken;
|
||||
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ForensicSnapshotClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ForensicSnapshotClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotDocument> CreateSnapshotAsync(
|
||||
string tenant,
|
||||
ForensicSnapshotCreateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to create forensic snapshot (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Invalid response from forensic API.");
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
|
||||
ForensicSnapshotListQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildListRequestUri(query);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to list forensic snapshots (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ForensicSnapshotListResponse();
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotDocument?> GetSnapshotAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to get forensic snapshot (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotDocument>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}/manifest?tenant={Uri.EscapeDataString(tenant)}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError(
|
||||
"Failed to get forensic snapshot manifest (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<ForensicSnapshotManifest>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string BuildListRequestUri(ForensicSnapshotListQuery query)
|
||||
{
|
||||
var builder = new StringBuilder("/forensic/snapshots?tenant=");
|
||||
builder.Append(Uri.EscapeDataString(query.Tenant));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.CaseId))
|
||||
{
|
||||
builder.Append("&caseId=");
|
||||
builder.Append(Uri.EscapeDataString(query.CaseId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
builder.Append("&status=");
|
||||
builder.Append(Uri.EscapeDataString(query.Status));
|
||||
}
|
||||
|
||||
if (query.Tags is { Count: > 0 })
|
||||
{
|
||||
foreach (var tag in query.Tags)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
builder.Append("&tag=");
|
||||
builder.Append(Uri.EscapeDataString(tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.CreatedAfter.HasValue)
|
||||
{
|
||||
builder.Append("&createdAfter=");
|
||||
builder.Append(Uri.EscapeDataString(query.CreatedAfter.Value.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (query.CreatedBefore.HasValue)
|
||||
{
|
||||
builder.Append("&createdBefore=");
|
||||
builder.Append(Uri.EscapeDataString(query.CreatedBefore.Value.ToString("O", CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
builder.Append("&limit=");
|
||||
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (query.Offset.HasValue && query.Offset.Value > 0)
|
||||
{
|
||||
builder.Append("&offset=");
|
||||
builder.Append(query.Offset.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"BackendUrl is not configured. Set StellaOps:BackendUrl or STELLAOPS_BACKEND_URL.");
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
return _options.ApiKey;
|
||||
}
|
||||
|
||||
if (_tokenClient is null || string.IsNullOrWhiteSpace(_options.Authority.Url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_cachedAccessToken) && now < _cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var (scope, cacheKey) = BuildScopeAndCacheKey(_options);
|
||||
var cachedEntry = await _tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
|
||||
{
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = cachedEntry.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
StellaOpsTokenResult token;
|
||||
if (!string.IsNullOrWhiteSpace(_options.Authority.Username))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.Authority.Password))
|
||||
{
|
||||
throw new InvalidOperationException("Authority password must be configured when username is provided.");
|
||||
}
|
||||
|
||||
token = await _tokenClient.RequestPasswordTokenAsync(
|
||||
_options.Authority.Username,
|
||||
_options.Authority.Password!,
|
||||
scope,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_tokenSync)
|
||||
{
|
||||
_cachedAccessToken = token.AccessToken;
|
||||
_cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
|
||||
{
|
||||
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
|
||||
var finalScope = EnsureScope(baseScope, StellaOpsScopes.EvidenceRead);
|
||||
|
||||
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
|
||||
? $"user:{options.Authority.Username}"
|
||||
: $"client:{options.Authority.ClientId}";
|
||||
|
||||
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
|
||||
return (finalScope, cacheKey);
|
||||
}
|
||||
|
||||
private static string EnsureScope(string scopes, string required)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopes))
|
||||
{
|
||||
return required;
|
||||
}
|
||||
|
||||
var parts = scopes
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static scope => scope.ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (!parts.Contains(required, StringComparer.Ordinal))
|
||||
{
|
||||
parts.Add(required);
|
||||
}
|
||||
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
592
src/Cli/StellaOps.Cli/Services/ForensicVerifier.cs
Normal file
592
src/Cli/StellaOps.Cli/Services/ForensicVerifier.cs
Normal file
@@ -0,0 +1,592 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier for forensic bundles including checksums, DSSE signatures, and chain-of-custody.
|
||||
/// Per CLI-FORENSICS-54-001.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerifier : IForensicVerifier
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly ILogger<ForensicVerifier> _logger;
|
||||
|
||||
public ForensicVerifier(ILogger<ForensicVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ForensicVerificationResult> VerifyBundleAsync(
|
||||
string bundlePath,
|
||||
ForensicVerificationOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var errors = new List<ForensicVerificationError>();
|
||||
var warnings = new List<string>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogDebug("Verifying forensic bundle at {BundlePath}", bundlePath);
|
||||
|
||||
// Check bundle exists
|
||||
if (!File.Exists(bundlePath) && !Directory.Exists(bundlePath))
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleNotFound,
|
||||
Message = "Bundle path not found",
|
||||
Detail = bundlePath
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
var manifestPath = ResolveManifestPath(bundlePath);
|
||||
if (manifestPath is null || !File.Exists(manifestPath))
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleInvalid,
|
||||
Message = "Manifest not found in bundle",
|
||||
Detail = "Expected manifest.json in bundle root"
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
ForensicSnapshotManifest manifest;
|
||||
try
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
manifest = JsonSerializer.Deserialize<ForensicSnapshotManifest>(manifestJson, SerializerOptions)
|
||||
?? throw new InvalidDataException("Invalid manifest JSON");
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse manifest at {ManifestPath}", manifestPath);
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicBundleInvalid,
|
||||
Message = "Failed to parse manifest",
|
||||
Detail = ex.Message
|
||||
});
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = false,
|
||||
VerifiedAt = verifiedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
var bundleDir = Path.GetDirectoryName(manifestPath) ?? bundlePath;
|
||||
|
||||
// Verify manifest
|
||||
var manifestVerification = await VerifyManifestAsync(manifest, manifestPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!manifestVerification.IsValid)
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicChecksumMismatch,
|
||||
Message = "Manifest digest verification failed",
|
||||
Detail = $"Expected: {manifestVerification.Digest}, Computed: {manifestVerification.ComputedDigest}"
|
||||
});
|
||||
}
|
||||
|
||||
// Verify checksums
|
||||
ForensicChecksumVerification? checksumVerification = null;
|
||||
if (options.VerifyChecksums)
|
||||
{
|
||||
checksumVerification = await VerifyChecksumsAsync(manifest, bundleDir, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var failure in checksumVerification.FailedArtifacts)
|
||||
{
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = CliErrorCodes.ForensicChecksumMismatch,
|
||||
Message = $"Checksum mismatch for artifact {failure.ArtifactId}",
|
||||
Detail = failure.Reason,
|
||||
ArtifactId = failure.ArtifactId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signatures
|
||||
ForensicSignatureVerification? signatureVerification = null;
|
||||
if (options.VerifySignatures && manifest.Signature is not null)
|
||||
{
|
||||
var trustRoots = options.TrustRoots.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(options.TrustRootPath))
|
||||
{
|
||||
var loadedRoots = await LoadTrustRootsAsync(options.TrustRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
trustRoots.AddRange(loadedRoots);
|
||||
}
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
warnings.Add("No trust roots configured; signature verification skipped");
|
||||
}
|
||||
else
|
||||
{
|
||||
signatureVerification = VerifySignature(manifest, trustRoots);
|
||||
|
||||
if (!signatureVerification.IsValid)
|
||||
{
|
||||
var untrusted = signatureVerification.Signatures
|
||||
.Where(s => !s.IsTrusted)
|
||||
.Select(s => s.KeyId);
|
||||
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = signatureVerification.VerifiedSignatures == 0
|
||||
? CliErrorCodes.ForensicSignatureInvalid
|
||||
: CliErrorCodes.ForensicSignatureUntrusted,
|
||||
Message = "Signature verification failed",
|
||||
Detail = string.Join(", ", signatureVerification.Signatures.Select(s => s.Reason).Where(r => r is not null))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify chain of custody
|
||||
ForensicChainOfCustodyVerification? chainVerification = null;
|
||||
if (options.VerifyChainOfCustody && manifest.Metadata?.ChainOfCustody is { Count: > 0 })
|
||||
{
|
||||
chainVerification = VerifyChainOfCustody(manifest.Metadata.ChainOfCustody, options.StrictTimeline);
|
||||
|
||||
if (!chainVerification.IsValid)
|
||||
{
|
||||
var errorCode = !chainVerification.TimelineValid
|
||||
? CliErrorCodes.ForensicTimelineInvalid
|
||||
: CliErrorCodes.ForensicChainOfCustodyBroken;
|
||||
|
||||
errors.Add(new ForensicVerificationError
|
||||
{
|
||||
Code = errorCode,
|
||||
Message = "Chain of custody verification failed",
|
||||
Detail = chainVerification.Gaps.Count > 0
|
||||
? $"Found {chainVerification.Gaps.Count} timeline gap(s)"
|
||||
: "Invalid entry signatures"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var isValid = errors.Count == 0 &&
|
||||
manifestVerification.IsValid &&
|
||||
(checksumVerification?.IsValid ?? true) &&
|
||||
(signatureVerification?.IsValid ?? true) &&
|
||||
(chainVerification?.IsValid ?? true);
|
||||
|
||||
return new ForensicVerificationResult
|
||||
{
|
||||
BundlePath = bundlePath,
|
||||
IsValid = isValid,
|
||||
VerifiedAt = verifiedAt,
|
||||
ManifestVerification = manifestVerification,
|
||||
ChecksumVerification = checksumVerification,
|
||||
SignatureVerification = signatureVerification,
|
||||
ChainOfCustodyVerification = chainVerification,
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
|
||||
string trustRootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(trustRootPath);
|
||||
|
||||
if (!File.Exists(trustRootPath))
|
||||
{
|
||||
_logger.LogWarning("Trust root file not found: {Path}", trustRootPath);
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(trustRootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Try array format first
|
||||
var roots = JsonSerializer.Deserialize<List<ForensicTrustRoot>>(json, SerializerOptions);
|
||||
if (roots is not null)
|
||||
{
|
||||
return roots;
|
||||
}
|
||||
|
||||
// Try single object
|
||||
var singleRoot = JsonSerializer.Deserialize<ForensicTrustRoot>(json, SerializerOptions);
|
||||
if (singleRoot is not null)
|
||||
{
|
||||
return new[] { singleRoot };
|
||||
}
|
||||
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse trust roots from {Path}", trustRootPath);
|
||||
return Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveManifestPath(string bundlePath)
|
||||
{
|
||||
if (File.Exists(bundlePath))
|
||||
{
|
||||
// If bundlePath is a file, check if it's the manifest
|
||||
var fileName = Path.GetFileName(bundlePath);
|
||||
if (fileName.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
// Otherwise look for manifest in same directory
|
||||
var dir = Path.GetDirectoryName(bundlePath);
|
||||
if (dir is not null)
|
||||
{
|
||||
var manifestInDir = Path.Combine(dir, "manifest.json");
|
||||
if (File.Exists(manifestInDir))
|
||||
{
|
||||
return manifestInDir;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Directory.Exists(bundlePath))
|
||||
{
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
return File.Exists(manifestPath) ? manifestPath : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<ForensicManifestVerification> VerifyManifestAsync(
|
||||
ForensicSnapshotManifest manifest,
|
||||
string manifestPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
var computedDigest = ComputeDigest(manifestBytes, manifest.DigestAlgorithm);
|
||||
|
||||
var isValid = string.Equals(manifest.Digest, computedDigest, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.IsNullOrEmpty(manifest.Digest); // Allow empty digest for unsigned manifests
|
||||
|
||||
return new ForensicManifestVerification
|
||||
{
|
||||
IsValid = isValid,
|
||||
ManifestId = manifest.ManifestId,
|
||||
Version = manifest.Version,
|
||||
Digest = manifest.Digest,
|
||||
DigestAlgorithm = manifest.DigestAlgorithm,
|
||||
ComputedDigest = computedDigest,
|
||||
ArtifactCount = manifest.Artifacts.Count
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ForensicChecksumVerification> VerifyChecksumsAsync(
|
||||
ForensicSnapshotManifest manifest,
|
||||
string bundleDir,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<ForensicArtifactChecksumFailure>();
|
||||
var verified = 0;
|
||||
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
{
|
||||
var artifactPath = Path.Combine(bundleDir, artifact.Path);
|
||||
|
||||
if (!File.Exists(artifactPath))
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = string.Empty,
|
||||
Reason = "Artifact file not found"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileBytes = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false);
|
||||
var actualDigest = ComputeDigest(fileBytes, artifact.DigestAlgorithm);
|
||||
|
||||
if (!string.Equals(artifact.Digest, actualDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = actualDigest,
|
||||
Reason = "Digest mismatch"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
verified++;
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
failures.Add(new ForensicArtifactChecksumFailure
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
Path = artifact.Path,
|
||||
ExpectedDigest = artifact.Digest,
|
||||
ActualDigest = string.Empty,
|
||||
Reason = $"IO error: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ForensicChecksumVerification
|
||||
{
|
||||
IsValid = failures.Count == 0,
|
||||
TotalArtifacts = manifest.Artifacts.Count,
|
||||
VerifiedArtifacts = verified,
|
||||
FailedArtifacts = failures
|
||||
};
|
||||
}
|
||||
|
||||
private ForensicSignatureVerification VerifySignature(
|
||||
ForensicSnapshotManifest manifest,
|
||||
IReadOnlyList<ForensicTrustRoot> trustRoots)
|
||||
{
|
||||
if (manifest.Signature is null)
|
||||
{
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = false,
|
||||
SignatureCount = 0,
|
||||
VerifiedSignatures = 0,
|
||||
Signatures = Array.Empty<ForensicSignatureDetail>()
|
||||
};
|
||||
}
|
||||
|
||||
var signatures = new List<ForensicSignatureDetail>();
|
||||
var verifiedCount = 0;
|
||||
|
||||
// Find matching trust root
|
||||
var matchingRoot = trustRoots.FirstOrDefault(tr =>
|
||||
string.Equals(tr.KeyId, manifest.Signature.KeyId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingRoot is null)
|
||||
{
|
||||
signatures.Add(new ForensicSignatureDetail
|
||||
{
|
||||
KeyId = manifest.Signature.KeyId ?? "unknown",
|
||||
Algorithm = manifest.Signature.Algorithm,
|
||||
IsValid = false,
|
||||
IsTrusted = false,
|
||||
SignedAt = manifest.Signature.SignedAt,
|
||||
Reason = "No matching trust root found"
|
||||
});
|
||||
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = false,
|
||||
SignatureCount = 1,
|
||||
VerifiedSignatures = 0,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
var isValid = VerifyRsaPssSignature(
|
||||
manifest.Digest,
|
||||
manifest.Signature.Value,
|
||||
matchingRoot.PublicKey);
|
||||
|
||||
// Check time validity
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
|
||||
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);
|
||||
|
||||
if (isValid && timeValid)
|
||||
{
|
||||
verifiedCount++;
|
||||
}
|
||||
|
||||
signatures.Add(new ForensicSignatureDetail
|
||||
{
|
||||
KeyId = manifest.Signature.KeyId ?? "unknown",
|
||||
Algorithm = manifest.Signature.Algorithm,
|
||||
IsValid = isValid,
|
||||
IsTrusted = isValid && timeValid,
|
||||
SignedAt = manifest.Signature.SignedAt,
|
||||
Fingerprint = matchingRoot.Fingerprint,
|
||||
Reason = !isValid ? "Signature verification failed" :
|
||||
!timeValid ? "Key outside validity period" : null
|
||||
});
|
||||
|
||||
return new ForensicSignatureVerification
|
||||
{
|
||||
IsValid = verifiedCount > 0,
|
||||
SignatureCount = 1,
|
||||
VerifiedSignatures = verifiedCount,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private ForensicChainOfCustodyVerification VerifyChainOfCustody(
|
||||
IReadOnlyList<ForensicChainOfCustodyEntry> entries,
|
||||
bool strictTimeline)
|
||||
{
|
||||
var entryVerifications = new List<ForensicChainOfCustodyEntryVerification>();
|
||||
var gaps = new List<ForensicTimelineGap>();
|
||||
var timelineValid = true;
|
||||
var signaturesValid = true;
|
||||
|
||||
DateTimeOffset? lastTimestamp = null;
|
||||
var index = 0;
|
||||
|
||||
foreach (var entry in entries.OrderBy(e => e.Timestamp))
|
||||
{
|
||||
// Check timeline progression
|
||||
if (lastTimestamp.HasValue && entry.Timestamp < lastTimestamp.Value)
|
||||
{
|
||||
timelineValid = false;
|
||||
gaps.Add(new ForensicTimelineGap
|
||||
{
|
||||
FromIndex = index - 1,
|
||||
ToIndex = index,
|
||||
FromTimestamp = lastTimestamp.Value,
|
||||
ToTimestamp = entry.Timestamp,
|
||||
GapDuration = lastTimestamp.Value - entry.Timestamp,
|
||||
Description = "Timestamp out of order"
|
||||
});
|
||||
}
|
||||
else if (strictTimeline && lastTimestamp.HasValue)
|
||||
{
|
||||
var gap = entry.Timestamp - lastTimestamp.Value;
|
||||
if (gap > TimeSpan.FromDays(1))
|
||||
{
|
||||
gaps.Add(new ForensicTimelineGap
|
||||
{
|
||||
FromIndex = index - 1,
|
||||
ToIndex = index,
|
||||
FromTimestamp = lastTimestamp.Value,
|
||||
ToTimestamp = entry.Timestamp,
|
||||
GapDuration = gap,
|
||||
Description = $"Large gap of {gap.TotalHours:F1} hours"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Signature verification (if present)
|
||||
bool? signatureValid = null;
|
||||
if (!string.IsNullOrWhiteSpace(entry.Signature))
|
||||
{
|
||||
// For now, just check signature is present
|
||||
// Full verification would require the signing key
|
||||
signatureValid = true;
|
||||
}
|
||||
|
||||
entryVerifications.Add(new ForensicChainOfCustodyEntryVerification
|
||||
{
|
||||
Index = index,
|
||||
Action = entry.Action,
|
||||
Actor = entry.Actor,
|
||||
Timestamp = entry.Timestamp,
|
||||
SignatureValid = signatureValid,
|
||||
Notes = entry.Notes
|
||||
});
|
||||
|
||||
lastTimestamp = entry.Timestamp;
|
||||
index++;
|
||||
}
|
||||
|
||||
return new ForensicChainOfCustodyVerification
|
||||
{
|
||||
IsValid = timelineValid && signaturesValid && (gaps.Count == 0 || !strictTimeline),
|
||||
EntryCount = entries.Count,
|
||||
TimelineValid = timelineValid,
|
||||
SignaturesValid = signaturesValid,
|
||||
Entries = entryVerifications,
|
||||
Gaps = gaps
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] data, string algorithm)
|
||||
{
|
||||
byte[] hash;
|
||||
switch (algorithm.ToLowerInvariant())
|
||||
{
|
||||
case "sha256":
|
||||
hash = SHA256.HashData(data);
|
||||
break;
|
||||
case "sha384":
|
||||
hash = SHA384.HashData(data);
|
||||
break;
|
||||
case "sha512":
|
||||
hash = SHA512.HashData(data);
|
||||
break;
|
||||
default:
|
||||
hash = SHA256.HashData(data);
|
||||
break;
|
||||
}
|
||||
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool VerifyRsaPssSignature(string digest, string signatureBase64, string publicKeyBase64)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publicKeyBytes = Convert.FromBase64String(publicKeyBase64);
|
||||
var signatureBytes = Convert.FromBase64String(signatureBase64);
|
||||
var digestBytes = Convert.FromHexString(digest);
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out _);
|
||||
|
||||
return rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/Cli/StellaOps.Cli/Services/IAttestationReader.cs
Normal file
20
src/Cli/StellaOps.Cli/Services/IAttestationReader.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reader for attestation files.
|
||||
/// Per CLI-FORENSICS-54-002.
|
||||
/// </summary>
|
||||
internal interface IAttestationReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads and parses an attestation file.
|
||||
/// </summary>
|
||||
Task<AttestationShowResult> ReadAttestationAsync(
|
||||
string filePath,
|
||||
AttestationShowOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -83,4 +83,48 @@ internal interface IBackendOperationsClient
|
||||
// CLI-VULN-29-005: Vulnerability export
|
||||
Task<VulnExportResponse> ExportVulnerabilitiesAsync(VulnExportRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
Task<Stream> DownloadVulnExportAsync(string exportId, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-23-006: Policy history and explain
|
||||
Task<PolicyHistoryResponse> GetPolicyHistoryAsync(PolicyHistoryRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyExplainResult> GetPolicyExplainAsync(PolicyExplainRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-27-002: Policy submission/review workflow
|
||||
Task<PolicyVersionBumpResult> BumpPolicyVersionAsync(PolicyVersionBumpRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicySubmitResult> SubmitPolicyForReviewAsync(PolicySubmitRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyReviewCommentResult> AddPolicyReviewCommentAsync(PolicyReviewCommentRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyApproveResult> ApprovePolicyReviewAsync(PolicyApproveRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyRejectResult> RejectPolicyReviewAsync(PolicyRejectRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyReviewSummary?> GetPolicyReviewStatusAsync(PolicyReviewStatusRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-POLICY-27-004: Policy lifecycle (publish/promote/rollback/sign)
|
||||
Task<PolicyPublishResult> PublishPolicyAsync(PolicyPublishRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyPromoteResult> PromotePolicyAsync(PolicyPromoteRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyRollbackResult> RollbackPolicyAsync(PolicyRollbackRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicySignResult> SignPolicyAsync(PolicySignRequest request, CancellationToken cancellationToken);
|
||||
Task<PolicyVerifySignatureResult> VerifyPolicySignatureAsync(PolicyVerifySignatureRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-66-001: Risk profile list
|
||||
Task<RiskProfileListResponse> ListRiskProfilesAsync(RiskProfileListRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-66-002: Risk simulate
|
||||
Task<RiskSimulateResult> SimulateRiskAsync(RiskSimulateRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-67-001: Risk results
|
||||
Task<RiskResultsResponse> GetRiskResultsAsync(RiskResultsRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-RISK-68-001: Risk bundle verify
|
||||
Task<RiskBundleVerifyResult> VerifyRiskBundleAsync(RiskBundleVerifyRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SIG-26-001: Reachability operations
|
||||
Task<ReachabilityUploadCallGraphResult> UploadCallGraphAsync(ReachabilityUploadCallGraphRequest request, Stream callGraphStream, CancellationToken cancellationToken);
|
||||
Task<ReachabilityListResponse> ListReachabilityAnalysesAsync(ReachabilityListRequest request, CancellationToken cancellationToken);
|
||||
Task<ReachabilityExplainResult> ExplainReachabilityAsync(ReachabilityExplainRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SDK-63-001: API spec download
|
||||
Task<ApiSpecListResponse> ListApiSpecsAsync(string? tenant, CancellationToken cancellationToken);
|
||||
Task<ApiSpecDownloadResult> DownloadApiSpecAsync(ApiSpecDownloadRequest request, CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SDK-64-001: SDK update
|
||||
Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken);
|
||||
Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,32 @@ using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Concelier advisory observations API.
|
||||
/// Per CLI-LNM-22-001, supports obs get, linkset show, and export operations.
|
||||
/// </summary>
|
||||
internal interface IConcelierObservationsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets advisory observations matching the query.
|
||||
/// </summary>
|
||||
Task<AdvisoryObservationsResponse> GetObservationsAsync(
|
||||
AdvisoryObservationsQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets advisory linkset with conflict information.
|
||||
/// Per CLI-LNM-22-001, includes conflict display.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetResponse> GetLinksetAsync(
|
||||
AdvisoryLinksetQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single observation by ID.
|
||||
/// </summary>
|
||||
Task<AdvisoryLinksetObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
46
src/Cli/StellaOps.Cli/Services/IDeterminismHarness.cs
Normal file
46
src/Cli/StellaOps.Cli/Services/IDeterminismHarness.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism harness for running scanner with frozen conditions.
|
||||
/// Per CLI-DETER-70-003/004.
|
||||
/// </summary>
|
||||
internal interface IDeterminismHarness
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs the determinism harness with the specified configuration.
|
||||
/// </summary>
|
||||
Task<DeterminismRunResult> RunAsync(
|
||||
DeterminismRunRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a determinism manifest against thresholds.
|
||||
/// </summary>
|
||||
DeterminismVerificationResult VerifyManifest(
|
||||
DeterminismManifest manifest,
|
||||
double imageThreshold,
|
||||
double overallThreshold);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a determinism report from multiple manifest files.
|
||||
/// Per CLI-DETER-70-004.
|
||||
/// </summary>
|
||||
Task<DeterminismReportResult> GenerateReportAsync(
|
||||
DeterminismReportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a determinism manifest.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismVerificationResult
|
||||
{
|
||||
public bool Passed { get; init; }
|
||||
public double OverallScore { get; init; }
|
||||
public string[] FailedImages { get; init; } = System.Array.Empty<string>();
|
||||
public string[] Warnings { get; init; } = System.Array.Empty<string>();
|
||||
}
|
||||
64
src/Cli/StellaOps.Cli/Services/IExceptionClient.cs
Normal file
64
src/Cli/StellaOps.Cli/Services/IExceptionClient.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for exception governance API operations.
|
||||
/// Per CLI-EXC-25-001.
|
||||
/// </summary>
|
||||
internal interface IExceptionClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists exceptions matching the query.
|
||||
/// </summary>
|
||||
Task<ExceptionListResponse> ListAsync(
|
||||
ExceptionListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an exception by ID.
|
||||
/// </summary>
|
||||
Task<ExceptionInstance?> GetAsync(
|
||||
string exceptionId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new exception.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> CreateAsync(
|
||||
ExceptionCreateRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Promotes an exception to the next lifecycle stage.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> PromoteAsync(
|
||||
ExceptionPromoteRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an exception.
|
||||
/// </summary>
|
||||
Task<ExceptionOperationResult> RevokeAsync(
|
||||
ExceptionRevokeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Imports exceptions from NDJSON stream.
|
||||
/// </summary>
|
||||
Task<ExceptionImportResult> ImportAsync(
|
||||
ExceptionImportRequest request,
|
||||
Stream ndjsonStream,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Exports exceptions to a stream.
|
||||
/// </summary>
|
||||
Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync(
|
||||
ExceptionExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
43
src/Cli/StellaOps.Cli/Services/IForensicSnapshotClient.cs
Normal file
43
src/Cli/StellaOps.Cli/Services/IForensicSnapshotClient.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for forensic snapshot and evidence locker APIs.
|
||||
/// Per CLI-FORENSICS-53-001.
|
||||
/// </summary>
|
||||
internal interface IForensicSnapshotClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new forensic snapshot.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotDocument> CreateSnapshotAsync(
|
||||
string tenant,
|
||||
ForensicSnapshotCreateRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists forensic snapshots matching the query.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotListResponse> ListSnapshotsAsync(
|
||||
ForensicSnapshotListQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a forensic snapshot by ID.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotDocument?> GetSnapshotAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the manifest for a forensic snapshot.
|
||||
/// </summary>
|
||||
Task<ForensicSnapshotManifest?> GetSnapshotManifestAsync(
|
||||
string tenant,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
27
src/Cli/StellaOps.Cli/Services/IForensicVerifier.cs
Normal file
27
src/Cli/StellaOps.Cli/Services/IForensicVerifier.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifier for forensic bundles.
|
||||
/// Per CLI-FORENSICS-54-001.
|
||||
/// </summary>
|
||||
internal interface IForensicVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a forensic bundle at the specified path.
|
||||
/// </summary>
|
||||
Task<ForensicVerificationResult> VerifyBundleAsync(
|
||||
string bundlePath,
|
||||
ForensicVerificationOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Loads trust roots from a file path.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ForensicTrustRoot>> LoadTrustRootsAsync(
|
||||
string trustRootPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
70
src/Cli/StellaOps.Cli/Services/INotifyClient.cs
Normal file
70
src/Cli/StellaOps.Cli/Services/INotifyClient.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Notify API operations.
|
||||
/// Per CLI-PARITY-41-002.
|
||||
/// </summary>
|
||||
internal interface INotifyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists notification channels.
|
||||
/// </summary>
|
||||
Task<NotifyChannelListResponse> ListChannelsAsync(
|
||||
NotifyChannelListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a notification channel by ID.
|
||||
/// </summary>
|
||||
Task<NotifyChannelDetail?> GetChannelAsync(
|
||||
string channelId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Tests a notification channel.
|
||||
/// </summary>
|
||||
Task<NotifyChannelTestResult> TestChannelAsync(
|
||||
NotifyChannelTestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists notification rules.
|
||||
/// </summary>
|
||||
Task<NotifyRuleListResponse> ListRulesAsync(
|
||||
NotifyRuleListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists notification deliveries.
|
||||
/// </summary>
|
||||
Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
|
||||
NotifyDeliveryListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a delivery by ID.
|
||||
/// </summary>
|
||||
Task<NotifyDeliveryDetail?> GetDeliveryAsync(
|
||||
string deliveryId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retries a failed delivery.
|
||||
/// </summary>
|
||||
Task<NotifyRetryResult> RetryDeliveryAsync(
|
||||
NotifyRetryRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification.
|
||||
/// </summary>
|
||||
Task<NotifySendResult> SendAsync(
|
||||
NotifySendRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
59
src/Cli/StellaOps.Cli/Services/IObservabilityClient.cs
Normal file
59
src/Cli/StellaOps.Cli/Services/IObservabilityClient.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for observability API operations.
|
||||
/// Per CLI-OBS-51-001/52-001.
|
||||
/// </summary>
|
||||
internal interface IObservabilityClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets platform health summary for obs top command.
|
||||
/// </summary>
|
||||
Task<ObsTopResult> GetHealthSummaryAsync(
|
||||
ObsTopRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a distributed trace by ID.
|
||||
/// Per CLI-OBS-52-001.
|
||||
/// </summary>
|
||||
Task<ObsTraceResult> GetTraceAsync(
|
||||
ObsTraceRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets logs within a time window.
|
||||
/// Per CLI-OBS-52-001.
|
||||
/// </summary>
|
||||
Task<ObsLogsResult> GetLogsAsync(
|
||||
ObsLogsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current incident mode status.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> GetIncidentModeStatusAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Enables incident mode.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> EnableIncidentModeAsync(
|
||||
IncidentModeEnableRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Disables incident mode.
|
||||
/// Per CLI-OBS-55-001.
|
||||
/// </summary>
|
||||
Task<IncidentModeResult> DisableIncidentModeAsync(
|
||||
IncidentModeDisableRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
102
src/Cli/StellaOps.Cli/Services/IOrchestratorClient.cs
Normal file
102
src/Cli/StellaOps.Cli/Services/IOrchestratorClient.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for orchestrator API operations.
|
||||
/// Per CLI-ORCH-32-001.
|
||||
/// </summary>
|
||||
internal interface IOrchestratorClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists sources matching the query.
|
||||
/// </summary>
|
||||
Task<SourceListResponse> ListSourcesAsync(
|
||||
SourceListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a source by ID.
|
||||
/// </summary>
|
||||
Task<OrchestratorSource?> GetSourceAsync(
|
||||
string sourceId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a source.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> PauseSourceAsync(
|
||||
SourcePauseRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused source.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> ResumeSourceAsync(
|
||||
SourceResumeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Tests a source connection.
|
||||
/// </summary>
|
||||
Task<SourceTestResult> TestSourceAsync(
|
||||
SourceTestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-ORCH-34-001: Backfill operations
|
||||
|
||||
/// <summary>
|
||||
/// Starts a backfill operation for a source.
|
||||
/// </summary>
|
||||
Task<BackfillResult> StartBackfillAsync(
|
||||
BackfillRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a backfill operation.
|
||||
/// </summary>
|
||||
Task<BackfillResult?> GetBackfillAsync(
|
||||
string backfillId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists backfill operations.
|
||||
/// </summary>
|
||||
Task<BackfillListResponse> ListBackfillsAsync(
|
||||
BackfillListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running backfill operation.
|
||||
/// </summary>
|
||||
Task<SourceOperationResult> CancelBackfillAsync(
|
||||
BackfillCancelRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-ORCH-34-001: Quota management
|
||||
|
||||
/// <summary>
|
||||
/// Gets quotas for a tenant/source.
|
||||
/// </summary>
|
||||
Task<QuotaGetResponse> GetQuotasAsync(
|
||||
QuotaGetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a quota limit.
|
||||
/// </summary>
|
||||
Task<QuotaOperationResult> SetQuotaAsync(
|
||||
QuotaSetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resets a quota's usage counter.
|
||||
/// </summary>
|
||||
Task<QuotaOperationResult> ResetQuotaAsync(
|
||||
QuotaResetRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
124
src/Cli/StellaOps.Cli/Services/IPackClient.cs
Normal file
124
src/Cli/StellaOps.Cli/Services/IPackClient.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Task Pack registry and runner API operations.
|
||||
/// Per CLI-PACKS-42-001.
|
||||
/// </summary>
|
||||
internal interface IPackClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Plans a pack execution without running it.
|
||||
/// Returns the execution graph, validation errors, and approval requirements.
|
||||
/// </summary>
|
||||
Task<PackPlanResult> PlanAsync(
|
||||
PackPlanRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a pack with the specified inputs.
|
||||
/// Can optionally wait for completion.
|
||||
/// </summary>
|
||||
Task<PackRunResult> RunAsync(
|
||||
PackRunRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of a pack run.
|
||||
/// </summary>
|
||||
Task<PackRunStatus?> GetRunStatusAsync(
|
||||
string runId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a pack to the registry.
|
||||
/// </summary>
|
||||
Task<PackPushResult> PushAsync(
|
||||
PackPushRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pulls a pack from the registry.
|
||||
/// </summary>
|
||||
Task<PackPullResult> PullAsync(
|
||||
PackPullRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a pack's signature, digest, and schema.
|
||||
/// </summary>
|
||||
Task<PackVerifyResult> VerifyAsync(
|
||||
PackVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets pack info from the registry.
|
||||
/// </summary>
|
||||
Task<TaskPackInfo?> GetPackInfoAsync(
|
||||
string packId,
|
||||
string? version,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-PACKS-43-001: Advanced pack features
|
||||
|
||||
/// <summary>
|
||||
/// Lists pack runs with optional filters.
|
||||
/// </summary>
|
||||
Task<PackRunListResponse> ListRunsAsync(
|
||||
PackRunListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running pack.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> CancelRunAsync(
|
||||
PackCancelRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a pack run waiting for approval.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> PauseForApprovalAsync(
|
||||
PackApprovalPauseRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused pack run with approval decision.
|
||||
/// </summary>
|
||||
Task<PackApprovalResult> ResumeWithApprovalAsync(
|
||||
PackApprovalResumeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Injects a secret into a pack run.
|
||||
/// </summary>
|
||||
Task<PackSecretInjectResult> InjectSecretAsync(
|
||||
PackSecretInjectRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets logs for a pack run.
|
||||
/// </summary>
|
||||
Task<PackLogsResult> GetLogsAsync(
|
||||
PackLogsRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an artifact from a pack run.
|
||||
/// </summary>
|
||||
Task<PackArtifactDownloadResult> DownloadArtifactAsync(
|
||||
PackArtifactDownloadRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Manages the offline pack cache.
|
||||
/// </summary>
|
||||
Task<PackCacheResult> ManageCacheAsync(
|
||||
PackCacheRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
42
src/Cli/StellaOps.Cli/Services/IPromotionAssembler.cs
Normal file
42
src/Cli/StellaOps.Cli/Services/IPromotionAssembler.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Assembler for promotion attestations.
|
||||
/// Per CLI-PROMO-70-001/002.
|
||||
/// </summary>
|
||||
internal interface IPromotionAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembles a promotion attestation from the provided request.
|
||||
/// </summary>
|
||||
Task<PromotionAssembleResult> AssembleAsync(
|
||||
PromotionAssembleRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves image digest from registry.
|
||||
/// </summary>
|
||||
Task<string?> ResolveImageDigestAsync(
|
||||
string imageRef,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Signs a promotion predicate and produces a DSSE bundle.
|
||||
/// Per CLI-PROMO-70-002.
|
||||
/// </summary>
|
||||
Task<PromotionAttestResult> AttestAsync(
|
||||
PromotionAttestRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a promotion attestation bundle offline.
|
||||
/// Per CLI-PROMO-70-002.
|
||||
/// </summary>
|
||||
Task<PromotionVerifyResult> VerifyAsync(
|
||||
PromotionVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
53
src/Cli/StellaOps.Cli/Services/ISbomClient.cs
Normal file
53
src/Cli/StellaOps.Cli/Services/ISbomClient.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for SBOM API operations.
|
||||
/// Per CLI-PARITY-41-001.
|
||||
/// </summary>
|
||||
internal interface ISbomClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists SBOMs matching the query.
|
||||
/// </summary>
|
||||
Task<SbomListResponse> ListAsync(
|
||||
SbomListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an SBOM by ID with optional explain information.
|
||||
/// </summary>
|
||||
Task<SbomDetailResponse?> GetAsync(
|
||||
string sbomId,
|
||||
string? tenant,
|
||||
bool includeComponents,
|
||||
bool includeVulnerabilities,
|
||||
bool includeLicenses,
|
||||
bool explain,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two SBOMs.
|
||||
/// </summary>
|
||||
Task<SbomCompareResponse?> CompareAsync(
|
||||
SbomCompareRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Exports an SBOM in the specified format.
|
||||
/// </summary>
|
||||
Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
|
||||
SbomExportRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parity matrix showing CLI command coverage.
|
||||
/// </summary>
|
||||
Task<ParityMatrixResponse> GetParityMatrixAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
78
src/Cli/StellaOps.Cli/Services/ISbomerClient.cs
Normal file
78
src/Cli/StellaOps.Cli/Services/ISbomerClient.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Sbomer API operations (layer fragments and composition).
|
||||
/// Per CLI-SBOM-60-001.
|
||||
/// </summary>
|
||||
internal interface ISbomerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists layer fragments for a scan.
|
||||
/// </summary>
|
||||
Task<SbomerLayerListResponse> ListLayersAsync(
|
||||
SbomerLayerListRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets layer fragment details.
|
||||
/// </summary>
|
||||
Task<SbomerLayerDetail?> GetLayerAsync(
|
||||
SbomerLayerShowRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a layer fragment DSSE signature.
|
||||
/// </summary>
|
||||
Task<SbomerLayerVerifyResult> VerifyLayerAsync(
|
||||
SbomerLayerVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the composition manifest for a scan.
|
||||
/// </summary>
|
||||
Task<CompositionManifest?> GetCompositionManifestAsync(
|
||||
SbomerCompositionShowRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Composes SBOM from layer fragments.
|
||||
/// </summary>
|
||||
Task<SbomerComposeResult> ComposeAsync(
|
||||
SbomerComposeRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies composition against manifest and fragments.
|
||||
/// </summary>
|
||||
Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
|
||||
SbomerCompositionVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets Merkle diagnostics for a composition.
|
||||
/// </summary>
|
||||
Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
|
||||
string scanId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection methods
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes drift between current SBOM and baseline.
|
||||
/// </summary>
|
||||
Task<SbomerDriftResult> AnalyzeDriftAsync(
|
||||
SbomerDriftRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies SBOM with local recomposition and drift detection.
|
||||
/// </summary>
|
||||
Task<SbomerDriftVerifyResult> VerifyDriftAsync(
|
||||
SbomerDriftVerifyRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
34
src/Cli/StellaOps.Cli/Services/IVexObservationsClient.cs
Normal file
34
src/Cli/StellaOps.Cli/Services/IVexObservationsClient.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for VEX observation queries.
|
||||
/// Per CLI-LNM-22-002.
|
||||
/// </summary>
|
||||
internal interface IVexObservationsClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets VEX observations matching the query.
|
||||
/// </summary>
|
||||
Task<VexObservationResponse> GetObservationsAsync(
|
||||
VexObservationQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a VEX linkset for a vulnerability ID.
|
||||
/// </summary>
|
||||
Task<VexLinksetResponse> GetLinksetAsync(
|
||||
VexLinksetQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single VEX observation by ID.
|
||||
/// </summary>
|
||||
Task<VexObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
503
src/Cli/StellaOps.Cli/Services/Models/AdvisoryLinksetModels.cs
Normal file
503
src/Cli/StellaOps.Cli/Services/Models/AdvisoryLinksetModels.cs
Normal file
@@ -0,0 +1,503 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-LNM-22-001: Advisory linkset models for obs get/linkset show/export commands
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory linkset query with additional filters for CLI-LNM-22-001.
|
||||
/// </summary>
|
||||
internal sealed record AdvisoryLinksetQuery(
|
||||
string Tenant,
|
||||
IReadOnlyList<string> ObservationIds,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> Purls,
|
||||
IReadOnlyList<string> Cpes,
|
||||
IReadOnlyList<string> Sources,
|
||||
string? Severity,
|
||||
bool? KevOnly,
|
||||
bool? HasFix,
|
||||
int? Limit,
|
||||
string? Cursor)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates from a basic AdvisoryObservationsQuery.
|
||||
/// </summary>
|
||||
public static AdvisoryLinksetQuery FromBasicQuery(AdvisoryObservationsQuery query)
|
||||
{
|
||||
return new AdvisoryLinksetQuery(
|
||||
query.Tenant,
|
||||
query.ObservationIds,
|
||||
query.Aliases,
|
||||
query.Purls,
|
||||
query.Cpes,
|
||||
Sources: Array.Empty<string>(),
|
||||
Severity: null,
|
||||
KevOnly: null,
|
||||
HasFix: null,
|
||||
query.Limit,
|
||||
query.Cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory linkset response with conflict information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<AdvisoryLinksetObservation> Observations { get; init; } =
|
||||
Array.Empty<AdvisoryLinksetObservation>();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryLinksetAggregate Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<AdvisoryLinksetConflict> Conflicts { get; init; } =
|
||||
Array.Empty<AdvisoryLinksetConflict>();
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public int? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended advisory observation with severity, KEV, and fix information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetObservation
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public AdvisoryObservationSource Source { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("upstream")]
|
||||
public AdvisoryObservationUpstream Upstream { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("linkset")]
|
||||
public AdvisoryObservationLinkset Linkset { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public AdvisoryLinksetSeverity? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("kev")]
|
||||
public AdvisoryLinksetKev? Kev { get; init; }
|
||||
|
||||
[JsonPropertyName("fix")]
|
||||
public AdvisoryLinksetFix? Fix { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory severity information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetSeverity
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cvssV3")]
|
||||
public AdvisoryLinksetCvss? CvssV3 { get; init; }
|
||||
|
||||
[JsonPropertyName("cvssV2")]
|
||||
public AdvisoryLinksetCvss? CvssV2 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score details.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetCvss
|
||||
{
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("vector")]
|
||||
public string? Vector { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// KEV (Known Exploited Vulnerabilities) information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetKev
|
||||
{
|
||||
[JsonPropertyName("listed")]
|
||||
public bool Listed { get; init; }
|
||||
|
||||
[JsonPropertyName("addedDate")]
|
||||
public DateTimeOffset? AddedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("dueDate")]
|
||||
public DateTimeOffset? DueDate { get; init; }
|
||||
|
||||
[JsonPropertyName("knownRansomwareCampaignUse")]
|
||||
public bool? KnownRansomwareCampaignUse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix availability information.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetFix
|
||||
{
|
||||
[JsonPropertyName("available")]
|
||||
public bool Available { get; init; }
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("advisoryLinks")]
|
||||
public IReadOnlyList<string> AdvisoryLinks { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated linkset with conflict summary.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetAggregate
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
|
||||
Array.Empty<AdvisoryObservationReference>();
|
||||
|
||||
[JsonPropertyName("sourceCoverage")]
|
||||
public AdvisorySourceCoverageSummary? SourceCoverage { get; init; }
|
||||
|
||||
[JsonPropertyName("conflictSummary")]
|
||||
public AdvisoryConflictSummary? ConflictSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source coverage summary across observations.
|
||||
/// </summary>
|
||||
internal sealed class AdvisorySourceCoverageSummary
|
||||
{
|
||||
[JsonPropertyName("totalSources")]
|
||||
public int TotalSources { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("coveragePercent")]
|
||||
public double CoveragePercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict summary for the linkset.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryConflictSummary
|
||||
{
|
||||
[JsonPropertyName("hasConflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("totalConflicts")]
|
||||
public int TotalConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("severityConflicts")]
|
||||
public int SeverityConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("kevConflicts")]
|
||||
public int KevConflicts { get; init; }
|
||||
|
||||
[JsonPropertyName("fixConflicts")]
|
||||
public int FixConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed conflict information between observations.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryLinksetConflict
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string Field { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<AdvisoryConflictSource> Sources { get; init; } =
|
||||
Array.Empty<AdvisoryConflictSource>();
|
||||
|
||||
[JsonPropertyName("resolution")]
|
||||
public string? Resolution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source contribution to a conflict.
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryConflictSource
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV (Open Source Vulnerability) format output.
|
||||
/// Per CLI-LNM-22-001, supports JSON/OSV output format.
|
||||
/// </summary>
|
||||
internal sealed class OsvVulnerability
|
||||
{
|
||||
[JsonPropertyName("schema_version")]
|
||||
public string SchemaVersion { get; init; } = "1.6.0";
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("modified")]
|
||||
public string Modified { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("published")]
|
||||
public string? Published { get; init; }
|
||||
|
||||
[JsonPropertyName("withdrawn")]
|
||||
public string? Withdrawn { get; init; }
|
||||
|
||||
[JsonPropertyName("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("related")]
|
||||
public IReadOnlyList<string> Related { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
|
||||
|
||||
[JsonPropertyName("affected")]
|
||||
public IReadOnlyList<OsvAffected> Affected { get; init; } = Array.Empty<OsvAffected>();
|
||||
|
||||
[JsonPropertyName("references")]
|
||||
public IReadOnlyList<OsvReference> References { get; init; } = Array.Empty<OsvReference>();
|
||||
|
||||
[JsonPropertyName("credits")]
|
||||
public IReadOnlyList<OsvCredit> Credits { get; init; } = Array.Empty<OsvCredit>();
|
||||
|
||||
[JsonPropertyName("database_specific")]
|
||||
public OsvDatabaseSpecific? DatabaseSpecific { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV severity entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvSeverity
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public string Score { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV affected package entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvAffected
|
||||
{
|
||||
[JsonPropertyName("package")]
|
||||
public OsvPackage Package { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public IReadOnlyList<OsvSeverity> Severity { get; init; } = Array.Empty<OsvSeverity>();
|
||||
|
||||
[JsonPropertyName("ranges")]
|
||||
public IReadOnlyList<OsvRange> Ranges { get; init; } = Array.Empty<OsvRange>();
|
||||
|
||||
[JsonPropertyName("versions")]
|
||||
public IReadOnlyList<string> Versions { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("ecosystem_specific")]
|
||||
public Dictionary<string, object>? EcosystemSpecific { get; init; }
|
||||
|
||||
[JsonPropertyName("database_specific")]
|
||||
public Dictionary<string, object>? DatabaseSpecific { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV package identifier.
|
||||
/// </summary>
|
||||
internal sealed class OsvPackage
|
||||
{
|
||||
[JsonPropertyName("ecosystem")]
|
||||
public string Ecosystem { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV version range.
|
||||
/// </summary>
|
||||
internal sealed class OsvRange
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("repo")]
|
||||
public string? Repo { get; init; }
|
||||
|
||||
[JsonPropertyName("events")]
|
||||
public IReadOnlyList<OsvEvent> Events { get; init; } = Array.Empty<OsvEvent>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV range event.
|
||||
/// </summary>
|
||||
internal sealed class OsvEvent
|
||||
{
|
||||
[JsonPropertyName("introduced")]
|
||||
public string? Introduced { get; init; }
|
||||
|
||||
[JsonPropertyName("fixed")]
|
||||
public string? Fixed { get; init; }
|
||||
|
||||
[JsonPropertyName("last_affected")]
|
||||
public string? LastAffected { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public string? Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV reference entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvReference
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV credit entry.
|
||||
/// </summary>
|
||||
internal sealed class OsvCredit
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("contact")]
|
||||
public IReadOnlyList<string> Contact { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV database-specific metadata.
|
||||
/// </summary>
|
||||
internal sealed class OsvDatabaseSpecific
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("kev")]
|
||||
public OsvKevInfo? Kev { get; init; }
|
||||
|
||||
[JsonPropertyName("stellaops")]
|
||||
public OsvStellaOpsInfo? StellaOps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OSV KEV information.
|
||||
/// </summary>
|
||||
internal sealed class OsvKevInfo
|
||||
{
|
||||
[JsonPropertyName("listed")]
|
||||
public bool Listed { get; init; }
|
||||
|
||||
[JsonPropertyName("added_date")]
|
||||
public string? AddedDate { get; init; }
|
||||
|
||||
[JsonPropertyName("due_date")]
|
||||
public string? DueDate { get; init; }
|
||||
|
||||
[JsonPropertyName("ransomware")]
|
||||
public bool? Ransomware { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// StellaOps-specific OSV metadata.
|
||||
/// </summary>
|
||||
internal sealed class OsvStellaOpsInfo
|
||||
{
|
||||
[JsonPropertyName("observation_ids")]
|
||||
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("has_conflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export format enumeration for advisory exports.
|
||||
/// </summary>
|
||||
internal enum AdvisoryExportFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON format (native StellaOps).
|
||||
/// </summary>
|
||||
Json,
|
||||
|
||||
/// <summary>
|
||||
/// OSV (Open Source Vulnerability) format.
|
||||
/// </summary>
|
||||
Osv,
|
||||
|
||||
/// <summary>
|
||||
/// NDJSON (newline-delimited JSON) format.
|
||||
/// </summary>
|
||||
Ndjson,
|
||||
|
||||
/// <summary>
|
||||
/// CSV format for spreadsheet imports.
|
||||
/// </summary>
|
||||
Csv
|
||||
}
|
||||
223
src/Cli/StellaOps.Cli/Services/Models/ApiSpecModels.cs
Normal file
223
src/Cli/StellaOps.Cli/Services/Models/ApiSpecModels.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request for downloading API specification.
|
||||
/// CLI-SDK-63-001: Exposes stella api spec download command.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecDownloadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant context for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public required string OutputPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Spec format to download (openapi-json, openapi-yaml).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "openapi-json";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to overwrite existing files.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool Overwrite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional service filter (e.g., "concelier", "scanner", "policy").
|
||||
/// When null, downloads the aggregate/combined spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service")]
|
||||
public string? Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected ETag for conditional download (If-None-Match).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? ExpectedETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected checksum for verification after download.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? ExpectedChecksum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checksum algorithm (sha256, sha384, sha512).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ChecksumAlgorithm { get; init; } = "sha256";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of API spec download operation.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecDownloadResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path where the spec was downloaded.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size of the downloaded spec in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the result was served from cache (304 Not Modified).
|
||||
/// </summary>
|
||||
[JsonPropertyName("fromCache")]
|
||||
public bool FromCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ETag of the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("etag")]
|
||||
public string? ETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Computed checksum of the downloaded spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksum")]
|
||||
public string? Checksum { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checksum algorithm used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksumAlgorithm")]
|
||||
public string? ChecksumAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether checksum verification passed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checksumVerified")]
|
||||
public bool? ChecksumVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// API version extracted from the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("apiVersion")]
|
||||
public string? ApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of when the spec was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset? GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("errorCode")]
|
||||
public string? ErrorCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about available API specifications.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Service name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("service")]
|
||||
public required string Service { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI spec version (e.g., "3.1.0").
|
||||
/// </summary>
|
||||
[JsonPropertyName("openApiVersion")]
|
||||
public string? OpenApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available formats.
|
||||
/// </summary>
|
||||
[JsonPropertyName("formats")]
|
||||
public IReadOnlyList<string> Formats { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// ETag for the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("etag")]
|
||||
public string? ETag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 checksum of the JSON format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last modified timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastModified")]
|
||||
public DateTimeOffset? LastModified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the spec.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing available API specifications.
|
||||
/// </summary>
|
||||
internal sealed class ApiSpecListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available API specifications.
|
||||
/// </summary>
|
||||
[JsonPropertyName("specs")]
|
||||
public IReadOnlyList<ApiSpecInfo> Specs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate spec info (combined all services).
|
||||
/// </summary>
|
||||
[JsonPropertyName("aggregate")]
|
||||
public ApiSpecInfo? Aggregate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
230
src/Cli/StellaOps.Cli/Services/Models/AttestationModels.cs
Normal file
230
src/Cli/StellaOps.Cli/Services/Models/AttestationModels.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-54-002: Attestation models for forensic attest show command
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope from attestation file.
|
||||
/// </summary>
|
||||
internal sealed class AttestationEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<AttestationSignature> Signatures { get; init; } = Array.Empty<AttestationSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature in attestation envelope.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-toto statement from attestation payload.
|
||||
/// </summary>
|
||||
internal sealed class InTotoStatement
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public IReadOnlyList<InTotoSubject> Subject { get; init; } = Array.Empty<InTotoSubject>();
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public object? Predicate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in in-toto statement.
|
||||
/// </summary>
|
||||
internal sealed class InTotoSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of attestation show operation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationShowResult
|
||||
{
|
||||
[JsonPropertyName("filePath")]
|
||||
public string FilePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("statementType")]
|
||||
public string StatementType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicateType")]
|
||||
public string PredicateType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subjects")]
|
||||
public IReadOnlyList<AttestationSubjectInfo> Subjects { get; init; } = Array.Empty<AttestationSubjectInfo>();
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<AttestationSignatureInfo> Signatures { get; init; } = Array.Empty<AttestationSignatureInfo>();
|
||||
|
||||
[JsonPropertyName("predicateSummary")]
|
||||
public AttestationPredicateSummary? PredicateSummary { get; init; }
|
||||
|
||||
[JsonPropertyName("verificationResult")]
|
||||
public AttestationVerificationResult? VerificationResult { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject information for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSubjectInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestValue")]
|
||||
public string DigestValue { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool? IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("isTrusted")]
|
||||
public bool? IsTrusted { get; init; }
|
||||
|
||||
[JsonPropertyName("signerInfo")]
|
||||
public AttestationSignerInfo? SignerInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signer information extracted from signature or certificate.
|
||||
/// </summary>
|
||||
internal sealed class AttestationSignerInfo
|
||||
{
|
||||
[JsonPropertyName("commonName")]
|
||||
public string? CommonName { get; init; }
|
||||
|
||||
[JsonPropertyName("organization")]
|
||||
public string? Organization { get; init; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the predicate for display.
|
||||
/// </summary>
|
||||
internal sealed class AttestationPredicateSummary
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("buildType")]
|
||||
public string? BuildType { get; init; }
|
||||
|
||||
[JsonPropertyName("builder")]
|
||||
public string? Builder { get; init; }
|
||||
|
||||
[JsonPropertyName("invocationId")]
|
||||
public string? InvocationId { get; init; }
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<AttestationMaterial>? Materials { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material in attestation predicate.
|
||||
/// </summary>
|
||||
internal sealed class AttestationMaterial
|
||||
{
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string>? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification result for attestation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationVerificationResult
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureCount")]
|
||||
public int SignatureCount { get; init; }
|
||||
|
||||
[JsonPropertyName("validSignatures")]
|
||||
public int ValidSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("trustedSignatures")]
|
||||
public int TrustedSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for attestation show operation.
|
||||
/// </summary>
|
||||
internal sealed class AttestationShowOptions
|
||||
{
|
||||
public bool VerifySignatures { get; init; } = true;
|
||||
public string? TrustRootPath { get; init; }
|
||||
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
|
||||
}
|
||||
420
src/Cli/StellaOps.Cli/Services/Models/DeterminismModels.cs
Normal file
420
src/Cli/StellaOps.Cli/Services/Models/DeterminismModels.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-DETER-70-003: Determinism score models (docs/modules/scanner/determinism-score.md)
|
||||
|
||||
/// <summary>
|
||||
/// Request for running determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Image digests to test.
|
||||
/// </summary>
|
||||
[JsonPropertyName("images")]
|
||||
public IReadOnlyList<string> Images { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Scanner container image reference.
|
||||
/// </summary>
|
||||
[JsonPropertyName("scanner")]
|
||||
public string Scanner { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle path or SHA.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyBundle")]
|
||||
public string? PolicyBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feeds bundle path or SHA.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feedsBundle")]
|
||||
public string? FeedsBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of runs per image (default 10).
|
||||
/// </summary>
|
||||
[JsonPropertyName("runs")]
|
||||
public int Runs { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed clock timestamp for deterministic execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fixedClock")]
|
||||
public DateTimeOffset? FixedClock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// RNG seed for deterministic execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rngSeed")]
|
||||
public int RngSeed { get; init; } = 1337;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum concurrency (default 1 for determinism).
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxConcurrency")]
|
||||
public int MaxConcurrency { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Memory limit for container (default 2G).
|
||||
/// </summary>
|
||||
[JsonPropertyName("memoryLimit")]
|
||||
public string MemoryLimit { get; init; } = "2G";
|
||||
|
||||
/// <summary>
|
||||
/// CPU set for container (default 0).
|
||||
/// </summary>
|
||||
[JsonPropertyName("cpuSet")]
|
||||
public string CpuSet { get; init; } = "0";
|
||||
|
||||
/// <summary>
|
||||
/// Platform (default linux/amd64).
|
||||
/// </summary>
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = "linux/amd64";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum threshold for individual image scores.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageThreshold")]
|
||||
public double ImageThreshold { get; init; } = 0.90;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum threshold for overall score.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overallThreshold")]
|
||||
public double OverallThreshold { get; init; } = 0.95;
|
||||
|
||||
/// <summary>
|
||||
/// Output directory for determinism.json and run artifacts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputDir")]
|
||||
public string? OutputDir { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release version string for the manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("release")]
|
||||
public string? Release { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determinism score manifest (determinism.json schema per SCAN-DETER-186-010).
|
||||
/// </summary>
|
||||
internal sealed class DeterminismManifest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1";
|
||||
|
||||
[JsonPropertyName("release")]
|
||||
public string Release { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = "linux/amd64";
|
||||
|
||||
[JsonPropertyName("policy_sha")]
|
||||
public string PolicySha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("feeds_sha")]
|
||||
public string FeedsSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanner_sha")]
|
||||
public string ScannerSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public IReadOnlyList<DeterminismImageResult> Images { get; init; } = Array.Empty<DeterminismImageResult>();
|
||||
|
||||
[JsonPropertyName("overall_score")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("thresholds")]
|
||||
public DeterminismThresholds Thresholds { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("generated_at")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("execution")]
|
||||
public DeterminismExecutionInfo? Execution { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-image determinism result.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismImageResult
|
||||
{
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("runs")]
|
||||
public int Runs { get; init; }
|
||||
|
||||
[JsonPropertyName("identical")]
|
||||
public int Identical { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_hashes")]
|
||||
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("non_deterministic")]
|
||||
public IReadOnlyList<string> NonDeterministic { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("run_details")]
|
||||
public IReadOnlyList<DeterminismRunDetail>? RunDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a single determinism run.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunDetail
|
||||
{
|
||||
[JsonPropertyName("run_number")]
|
||||
public int RunNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("identical")]
|
||||
public bool Identical { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact_hashes")]
|
||||
public IReadOnlyDictionary<string, string> ArtifactHashes { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("exit_code")]
|
||||
public int ExitCode { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thresholds for determinism scoring.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismThresholds
|
||||
{
|
||||
[JsonPropertyName("image_min")]
|
||||
public double ImageMin { get; init; } = 0.90;
|
||||
|
||||
[JsonPropertyName("overall_min")]
|
||||
public double OverallMin { get; init; } = 0.95;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution information for determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismExecutionInfo
|
||||
{
|
||||
[JsonPropertyName("fixed_clock")]
|
||||
public DateTimeOffset? FixedClock { get; init; }
|
||||
|
||||
[JsonPropertyName("rng_seed")]
|
||||
public int RngSeed { get; init; }
|
||||
|
||||
[JsonPropertyName("max_concurrency")]
|
||||
public int MaxConcurrency { get; init; }
|
||||
|
||||
[JsonPropertyName("memory_limit")]
|
||||
public string MemoryLimit { get; init; } = "2G";
|
||||
|
||||
[JsonPropertyName("cpu_set")]
|
||||
public string CpuSet { get; init; } = "0";
|
||||
|
||||
[JsonPropertyName("network_mode")]
|
||||
public string NetworkMode { get; init; } = "none";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of running the determinism harness.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismRunResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public DeterminismManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("passedThreshold")]
|
||||
public bool PassedThreshold { get; init; }
|
||||
|
||||
[JsonPropertyName("failedImages")]
|
||||
public IReadOnlyList<string> FailedImages { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
|
||||
// CLI-DETER-70-004: Determinism report models
|
||||
|
||||
/// <summary>
|
||||
/// Request for generating a determinism report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Paths to determinism.json files to include in report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("manifestPaths")]
|
||||
public IReadOnlyList<string> ManifestPaths { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Output format (markdown, json, csv).
|
||||
/// </summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "markdown";
|
||||
|
||||
/// <summary>
|
||||
/// Output path for the report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include detailed per-run information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeDetails")]
|
||||
public bool IncludeDetails { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Title for the report.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated determinism report across multiple manifests.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReport
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = "Determinism Score Report";
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public DeterminismReportSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("releases")]
|
||||
public IReadOnlyList<DeterminismReleaseEntry> Releases { get; init; } = Array.Empty<DeterminismReleaseEntry>();
|
||||
|
||||
[JsonPropertyName("imageMatrix")]
|
||||
public IReadOnlyList<DeterminismImageMatrixEntry> ImageMatrix { get; init; } = Array.Empty<DeterminismImageMatrixEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary statistics for the report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportSummary
|
||||
{
|
||||
[JsonPropertyName("totalReleases")]
|
||||
public int TotalReleases { get; init; }
|
||||
|
||||
[JsonPropertyName("totalImages")]
|
||||
public int TotalImages { get; init; }
|
||||
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public double MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("passedCount")]
|
||||
public int PassedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("failedCount")]
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("nonDeterministicArtifacts")]
|
||||
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a single release in the report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReleaseEntry
|
||||
{
|
||||
[JsonPropertyName("release")]
|
||||
public string Release { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("overallScore")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("passed")]
|
||||
public bool Passed { get; init; }
|
||||
|
||||
[JsonPropertyName("imageCount")]
|
||||
public int ImageCount { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("scannerSha")]
|
||||
public string ScannerSha { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifestPath")]
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-image matrix entry showing scores across releases.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismImageMatrixEntry
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scores")]
|
||||
public IReadOnlyDictionary<string, double> Scores { get; init; } = new Dictionary<string, double>();
|
||||
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("nonDeterministicArtifacts")]
|
||||
public IReadOnlyList<string> NonDeterministicArtifacts { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating a determinism report.
|
||||
/// </summary>
|
||||
internal sealed class DeterminismReportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("report")]
|
||||
public DeterminismReport? Report { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "markdown";
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
422
src/Cli/StellaOps.Cli/Services/Models/ExceptionModels.cs
Normal file
422
src/Cli/StellaOps.Cli/Services/Models/ExceptionModels.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-EXC-25-001: Exception governance models for stella exceptions commands
|
||||
|
||||
/// <summary>
|
||||
/// Exception scope types.
|
||||
/// </summary>
|
||||
internal static class ExceptionScopeTypes
|
||||
{
|
||||
public const string Purl = "purl";
|
||||
public const string Image = "image";
|
||||
public const string Component = "component";
|
||||
public const string TenantWide = "tenant";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception status values following lifecycle: draft -> staged -> active -> expired.
|
||||
/// </summary>
|
||||
internal static class ExceptionStatuses
|
||||
{
|
||||
public const string Draft = "draft";
|
||||
public const string Staged = "staged";
|
||||
public const string Active = "active";
|
||||
public const string Expired = "expired";
|
||||
public const string Revoked = "revoked";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception effect types.
|
||||
/// </summary>
|
||||
internal static class ExceptionEffectTypes
|
||||
{
|
||||
public const string Suppress = "suppress";
|
||||
public const string Defer = "defer";
|
||||
public const string Downgrade = "downgrade";
|
||||
public const string RequireControl = "requireControl";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception scope definition.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionScope
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = ExceptionScopeTypes.Purl;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ruleNames")]
|
||||
public IReadOnlyList<string>? RuleNames { get; init; }
|
||||
|
||||
[JsonPropertyName("severities")]
|
||||
public IReadOnlyList<string>? Severities { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence reference for an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionEvidenceRef
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // ticket, vex_claim, scan_report, attestation
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception effect definition.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionEffect
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("effectType")]
|
||||
public string EffectType { get; init; } = ExceptionEffectTypes.Suppress;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("downgradeSeverity")]
|
||||
public string? DowngradeSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("requiredControlId")]
|
||||
public string? RequiredControlId { get; init; }
|
||||
|
||||
[JsonPropertyName("routingTemplate")]
|
||||
public string? RoutingTemplate { get; init; }
|
||||
|
||||
[JsonPropertyName("maxDurationDays")]
|
||||
public int? MaxDurationDays { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception instance representing a governed waiver.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionInstance
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string Vuln { get; init; } = string.Empty; // CVE ID or alias
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ExceptionScope Scope { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("effectId")]
|
||||
public string EffectId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("effect")]
|
||||
public ExceptionEffect? Effect { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string Justification { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = ExceptionStatuses.Draft;
|
||||
|
||||
[JsonPropertyName("expiration")]
|
||||
public DateTimeOffset? Expiration { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("approvedBy")]
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("approvedAt")]
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<ExceptionEvidenceRef> EvidenceRefs { get; init; } = Array.Empty<ExceptionEvidenceRef>();
|
||||
|
||||
[JsonPropertyName("policyBinding")]
|
||||
public string? PolicyBinding { get; init; }
|
||||
|
||||
[JsonPropertyName("supersedes")]
|
||||
public string? Supersedes { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string? Vuln { get; init; }
|
||||
|
||||
[JsonPropertyName("scopeType")]
|
||||
public string? ScopeType { get; init; }
|
||||
|
||||
[JsonPropertyName("scopeValue")]
|
||||
public string? ScopeValue { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string? Owner { get; init; }
|
||||
|
||||
[JsonPropertyName("effectType")]
|
||||
public string? EffectType { get; init; }
|
||||
|
||||
[JsonPropertyName("expiringBefore")]
|
||||
public DateTimeOffset? ExpiringBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("includeExpired")]
|
||||
public bool IncludeExpired { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionListResponse
|
||||
{
|
||||
[JsonPropertyName("exceptions")]
|
||||
public IReadOnlyList<ExceptionInstance> Exceptions { get; init; } = Array.Empty<ExceptionInstance>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionCreateRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vuln")]
|
||||
public string Vuln { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ExceptionScope Scope { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("effectId")]
|
||||
public string EffectId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string Justification { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("owner")]
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expiration")]
|
||||
public DateTimeOffset? Expiration { get; init; }
|
||||
|
||||
[JsonPropertyName("evidenceRefs")]
|
||||
public IReadOnlyList<ExceptionEvidenceRef>? EvidenceRefs { get; init; }
|
||||
|
||||
[JsonPropertyName("policyBinding")]
|
||||
public string? PolicyBinding { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public bool Stage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to promote an exception (draft -> staged -> active).
|
||||
/// </summary>
|
||||
internal sealed class ExceptionPromoteRequest
|
||||
{
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public string ExceptionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("targetStatus")]
|
||||
public string TargetStatus { get; init; } = ExceptionStatuses.Active;
|
||||
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an exception.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionRevokeRequest
|
||||
{
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public string ExceptionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to import exceptions from NDJSON.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stage")]
|
||||
public bool Stage { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception import.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("imported")]
|
||||
public int Imported { get; init; }
|
||||
|
||||
[JsonPropertyName("skipped")]
|
||||
public int Skipped { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<ExceptionImportError> Errors { get; init; } = Array.Empty<ExceptionImportError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import error detail.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionImportError
|
||||
{
|
||||
[JsonPropertyName("line")]
|
||||
public int Line { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("field")]
|
||||
public string? Field { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to export exceptions.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionExportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "ndjson"; // ndjson, json
|
||||
|
||||
[JsonPropertyName("includeManifest")]
|
||||
public bool IncludeManifest { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export manifest for exception bundle.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionExportManifest
|
||||
{
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; init; }
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("aocEnforced")]
|
||||
public bool AocEnforced { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureUri")]
|
||||
public string? SignatureUri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of exception operation.
|
||||
/// </summary>
|
||||
internal sealed class ExceptionOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("exception")]
|
||||
public ExceptionInstance? Exception { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
279
src/Cli/StellaOps.Cli/Services/Models/ForensicSnapshotModels.cs
Normal file
279
src/Cli/StellaOps.Cli/Services/Models/ForensicSnapshotModels.cs
Normal file
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-53-001: Forensic snapshot models for evidence locker integration
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotCreateRequest(
|
||||
[property: JsonPropertyName("caseId")] string CaseId,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags = null,
|
||||
[property: JsonPropertyName("scope")] ForensicSnapshotScope? Scope = null,
|
||||
[property: JsonPropertyName("retentionDays")] int? RetentionDays = null);
|
||||
|
||||
/// <summary>
|
||||
/// Scope configuration for forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotScope(
|
||||
[property: JsonPropertyName("sbomIds")] IReadOnlyList<string>? SbomIds = null,
|
||||
[property: JsonPropertyName("scanIds")] IReadOnlyList<string>? ScanIds = null,
|
||||
[property: JsonPropertyName("policyIds")] IReadOnlyList<string>? PolicyIds = null,
|
||||
[property: JsonPropertyName("vulnerabilityIds")] IReadOnlyList<string>? VulnerabilityIds = null,
|
||||
[property: JsonPropertyName("timeRange")] ForensicTimeRange? TimeRange = null);
|
||||
|
||||
/// <summary>
|
||||
/// Time range for forensic snapshot scope.
|
||||
/// </summary>
|
||||
internal sealed record ForensicTimeRange(
|
||||
[property: JsonPropertyName("from")] DateTimeOffset? From = null,
|
||||
[property: JsonPropertyName("to")] DateTimeOffset? To = null);
|
||||
|
||||
/// <summary>
|
||||
/// Forensic snapshot document from the evidence locker.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotDocument
|
||||
{
|
||||
[JsonPropertyName("snapshotId")]
|
||||
public string SnapshotId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("caseId")]
|
||||
public string CaseId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public ForensicSnapshotManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public ForensicSnapshotScope? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long? SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactCount")]
|
||||
public int? ArtifactCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotManifest
|
||||
{
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string ManifestId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public ForensicManifestSignature? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<ForensicSnapshotArtifact> Artifacts { get; init; } =
|
||||
Array.Empty<ForensicSnapshotArtifact>();
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public ForensicManifestMetadata? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature information for the manifest.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestSignature
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("certificate")]
|
||||
public string? Certificate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual artifact in a forensic snapshot.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotArtifact
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for the forensic manifest.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestMetadata
|
||||
{
|
||||
[JsonPropertyName("capturedAt")]
|
||||
public DateTimeOffset CapturedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("capturedBy")]
|
||||
public string? CapturedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("toolVersion")]
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("stellaOpsVersion")]
|
||||
public string? StellaOpsVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("chainOfCustody")]
|
||||
public IReadOnlyList<ForensicChainOfCustodyEntry> ChainOfCustody { get; init; } =
|
||||
Array.Empty<ForensicChainOfCustodyEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain of custody entry.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyEntry
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotListResponse
|
||||
{
|
||||
[JsonPropertyName("snapshots")]
|
||||
public IReadOnlyList<ForensicSnapshotDocument> Snapshots { get; init; } =
|
||||
Array.Empty<ForensicSnapshotDocument>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed record ForensicSnapshotListQuery(
|
||||
string Tenant,
|
||||
string? CaseId = null,
|
||||
string? Status = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
DateTimeOffset? CreatedAfter = null,
|
||||
DateTimeOffset? CreatedBefore = null,
|
||||
int? Limit = null,
|
||||
int? Offset = null);
|
||||
|
||||
/// <summary>
|
||||
/// Local cache metadata for forensic snapshots.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSnapshotCacheEntry
|
||||
{
|
||||
[JsonPropertyName("snapshotId")]
|
||||
public string SnapshotId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("caseId")]
|
||||
public string CaseId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("localPath")]
|
||||
public string LocalPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifestDigest")]
|
||||
public string ManifestDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("downloadedAt")]
|
||||
public DateTimeOffset DownloadedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot status enumeration.
|
||||
/// </summary>
|
||||
internal static class ForensicSnapshotStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Creating = "creating";
|
||||
public const string Ready = "ready";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
public const string Archived = "archived";
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-FORENSICS-54-001: Forensic bundle verification models
|
||||
|
||||
/// <summary>
|
||||
/// Represents a forensic bundle for local verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicBundle
|
||||
{
|
||||
[JsonPropertyName("manifestPath")]
|
||||
public string ManifestPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public ForensicSnapshotManifest? Manifest { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<ForensicBundleArtifact> Artifacts { get; init; } = Array.Empty<ForensicBundleArtifact>();
|
||||
|
||||
[JsonPropertyName("dsseEnvelopes")]
|
||||
public IReadOnlyList<ForensicDsseEnvelope> DsseEnvelopes { get; init; } = Array.Empty<ForensicDsseEnvelope>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact in a forensic bundle with local file reference.
|
||||
/// </summary>
|
||||
internal sealed class ForensicBundleArtifact
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("localPath")]
|
||||
public string LocalPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("sizeBytes")]
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for signature verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicDsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<ForensicDsseSignature> Signatures { get; init; } = Array.Empty<ForensicDsseSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature entry.
|
||||
/// </summary>
|
||||
internal sealed class ForensicDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Signature { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of forensic bundle verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationResult
|
||||
{
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("manifestVerification")]
|
||||
public ForensicManifestVerification? ManifestVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("checksumVerification")]
|
||||
public ForensicChecksumVerification? ChecksumVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureVerification")]
|
||||
public ForensicSignatureVerification? SignatureVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("chainOfCustodyVerification")]
|
||||
public ForensicChainOfCustodyVerification? ChainOfCustodyVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<ForensicVerificationError> Errors { get; init; } = Array.Empty<ForensicVerificationError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicManifestVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("manifestId")]
|
||||
public string ManifestId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string DigestAlgorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("computedDigest")]
|
||||
public string ComputedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactCount")]
|
||||
public int ArtifactCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checksum verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChecksumVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("totalArtifacts")]
|
||||
public int TotalArtifacts { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedArtifacts")]
|
||||
public int VerifiedArtifacts { get; init; }
|
||||
|
||||
[JsonPropertyName("failedArtifacts")]
|
||||
public IReadOnlyList<ForensicArtifactChecksumFailure> FailedArtifacts { get; init; } =
|
||||
Array.Empty<ForensicArtifactChecksumFailure>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual artifact checksum failure.
|
||||
/// </summary>
|
||||
internal sealed class ForensicArtifactChecksumFailure
|
||||
{
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actualDigest")]
|
||||
public string ActualDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSignatureVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureCount")]
|
||||
public int SignatureCount { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedSignatures")]
|
||||
public int VerifiedSignatures { get; init; }
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<ForensicSignatureDetail> Signatures { get; init; } =
|
||||
Array.Empty<ForensicSignatureDetail>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual signature verification detail.
|
||||
/// </summary>
|
||||
internal sealed class ForensicSignatureDetail
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("isTrusted")]
|
||||
public bool IsTrusted { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain of custody verification result.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyVerification
|
||||
{
|
||||
[JsonPropertyName("isValid")]
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("entryCount")]
|
||||
public int EntryCount { get; init; }
|
||||
|
||||
[JsonPropertyName("timelineValid")]
|
||||
public bool TimelineValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signaturesValid")]
|
||||
public bool SignaturesValid { get; init; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<ForensicChainOfCustodyEntryVerification> Entries { get; init; } =
|
||||
Array.Empty<ForensicChainOfCustodyEntryVerification>();
|
||||
|
||||
[JsonPropertyName("gaps")]
|
||||
public IReadOnlyList<ForensicTimelineGap> Gaps { get; init; } = Array.Empty<ForensicTimelineGap>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual chain of custody entry verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicChainOfCustodyEntryVerification
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeline gap in chain of custody.
|
||||
/// </summary>
|
||||
internal sealed class ForensicTimelineGap
|
||||
{
|
||||
[JsonPropertyName("fromIndex")]
|
||||
public int FromIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("toIndex")]
|
||||
public int ToIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("fromTimestamp")]
|
||||
public DateTimeOffset FromTimestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("toTimestamp")]
|
||||
public DateTimeOffset ToTimestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("gapDuration")]
|
||||
public TimeSpan GapDuration { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification error detail.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationError
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("artifactId")]
|
||||
public string? ArtifactId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust root configuration for forensic verification.
|
||||
/// </summary>
|
||||
internal sealed class ForensicTrustRoot
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string Fingerprint { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("publicKey")]
|
||||
public string PublicKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = "rsa-pss-sha256";
|
||||
|
||||
[JsonPropertyName("notBefore")]
|
||||
public DateTimeOffset? NotBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("notAfter")]
|
||||
public DateTimeOffset? NotAfter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification options for forensic bundle.
|
||||
/// </summary>
|
||||
internal sealed class ForensicVerificationOptions
|
||||
{
|
||||
public bool VerifyChecksums { get; init; } = true;
|
||||
public bool VerifySignatures { get; init; } = true;
|
||||
public bool VerifyChainOfCustody { get; init; } = true;
|
||||
public bool StrictTimeline { get; init; } = false;
|
||||
public IReadOnlyList<ForensicTrustRoot> TrustRoots { get; init; } = Array.Empty<ForensicTrustRoot>();
|
||||
public string? TrustRootPath { get; init; }
|
||||
}
|
||||
612
src/Cli/StellaOps.Cli/Services/Models/NotifyModels.cs
Normal file
612
src/Cli/StellaOps.Cli/Services/Models/NotifyModels.cs
Normal file
@@ -0,0 +1,612 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PARITY-41-002: Notify command models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel types.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
internal enum NotifyChannelType
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
InApp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery status.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
internal enum NotifyDeliveryStatus
|
||||
{
|
||||
Pending,
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
Digested,
|
||||
Dropped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyChannelSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel summary for list view.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelSummary
|
||||
{
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryCount")]
|
||||
public int DeliveryCount { get; init; }
|
||||
|
||||
[JsonPropertyName("failureRate")]
|
||||
public double? FailureRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed notify channel response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelDetail
|
||||
{
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public NotifyChannelConfigInfo? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedBy")]
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("stats")]
|
||||
public NotifyChannelStats? Stats { get; init; }
|
||||
|
||||
[JsonPropertyName("health")]
|
||||
public NotifyChannelHealth? Health { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel configuration info (redacted secrets).
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelConfigInfo
|
||||
{
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; init; }
|
||||
|
||||
[JsonPropertyName("endpoint")]
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public IReadOnlyDictionary<string, string>? Properties { get; init; }
|
||||
|
||||
[JsonPropertyName("limits")]
|
||||
public NotifyChannelLimitsInfo? Limits { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel limits.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelLimitsInfo
|
||||
{
|
||||
[JsonPropertyName("concurrency")]
|
||||
public int? Concurrency { get; init; }
|
||||
|
||||
[JsonPropertyName("requestsPerMinute")]
|
||||
public int? RequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int? TimeoutSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("maxBatchSize")]
|
||||
public int? MaxBatchSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel statistics.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelStats
|
||||
{
|
||||
[JsonPropertyName("totalDeliveries")]
|
||||
public long TotalDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("successfulDeliveries")]
|
||||
public long SuccessfulDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("failedDeliveries")]
|
||||
public long FailedDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("throttledDeliveries")]
|
||||
public long ThrottledDeliveries { get; init; }
|
||||
|
||||
[JsonPropertyName("lastDeliveryAt")]
|
||||
public DateTimeOffset? LastDeliveryAt { get; init; }
|
||||
|
||||
[JsonPropertyName("avgLatencyMs")]
|
||||
public double? AvgLatencyMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify channel health status.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelHealth
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("lastCheckAt")]
|
||||
public DateTimeOffset? LastCheckAt { get; init; }
|
||||
|
||||
[JsonPropertyName("consecutiveFailures")]
|
||||
public int ConsecutiveFailures { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel test request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelTestRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Channel test result.
|
||||
/// </summary>
|
||||
internal sealed class NotifyChannelTestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("responseCode")]
|
||||
public int? ResponseCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string? EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyRuleSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify rule summary.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRuleSummary
|
||||
{
|
||||
[JsonPropertyName("ruleId")]
|
||||
public string RuleId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("eventTypes")]
|
||||
public IReadOnlyList<string> EventTypes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("channelIds")]
|
||||
public IReadOnlyList<string> ChannelIds { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("matchCount")]
|
||||
public long MatchCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery list request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string? EventType { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("until")]
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery list response.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<NotifyDeliverySummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery summary.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliverySummary
|
||||
{
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelName")]
|
||||
public string? ChannelName { get; init; }
|
||||
|
||||
[JsonPropertyName("channelType")]
|
||||
public string ChannelType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attemptCount")]
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sentAt")]
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery detail.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryDetail
|
||||
{
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("channelName")]
|
||||
public string? ChannelName { get; init; }
|
||||
|
||||
[JsonPropertyName("channelType")]
|
||||
public string ChannelType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ruleId")]
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public string? EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("attemptCount")]
|
||||
public int AttemptCount { get; init; }
|
||||
|
||||
[JsonPropertyName("attempts")]
|
||||
public IReadOnlyList<NotifyDeliveryAttempt>? Attempts { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("sentAt")]
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
|
||||
[JsonPropertyName("failedAt")]
|
||||
public DateTimeOffset? FailedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify delivery attempt.
|
||||
/// </summary>
|
||||
internal sealed class NotifyDeliveryAttempt
|
||||
{
|
||||
[JsonPropertyName("attemptNumber")]
|
||||
public int AttemptNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attemptedAt")]
|
||||
public DateTimeOffset AttemptedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("responseCode")]
|
||||
public int? ResponseCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry delivery request.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRetryRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry delivery result.
|
||||
/// </summary>
|
||||
internal sealed class NotifyRetryResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public string DeliveryId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("newStatus")]
|
||||
public string? NewStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send notification request.
|
||||
/// </summary>
|
||||
internal sealed class NotifySendRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; init; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send notification result.
|
||||
/// </summary>
|
||||
internal sealed class NotifySendResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public string? EventId { get; init; }
|
||||
|
||||
[JsonPropertyName("deliveryIds")]
|
||||
public IReadOnlyList<string>? DeliveryIds { get; init; }
|
||||
|
||||
[JsonPropertyName("channelsMatched")]
|
||||
public int ChannelsMatched { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("idempotencyKey")]
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
542
src/Cli/StellaOps.Cli/Services/Models/ObservabilityModels.cs
Normal file
542
src/Cli/StellaOps.Cli/Services/Models/ObservabilityModels.cs
Normal file
@@ -0,0 +1,542 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-OBS-51-001: Observability models for stella obs commands
|
||||
|
||||
/// <summary>
|
||||
/// Service health status from the platform.
|
||||
/// </summary>
|
||||
internal sealed class ServiceHealthStatus
|
||||
{
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "unknown"; // healthy, degraded, unhealthy, unknown
|
||||
|
||||
[JsonPropertyName("availability")]
|
||||
public double Availability { get; init; }
|
||||
|
||||
[JsonPropertyName("sloTarget")]
|
||||
public double SloTarget { get; init; } = 0.999;
|
||||
|
||||
[JsonPropertyName("errorBudgetRemaining")]
|
||||
public double ErrorBudgetRemaining { get; init; }
|
||||
|
||||
[JsonPropertyName("burnRate")]
|
||||
public BurnRateInfo? BurnRate { get; init; }
|
||||
|
||||
[JsonPropertyName("latency")]
|
||||
public LatencyInfo? Latency { get; init; }
|
||||
|
||||
[JsonPropertyName("traffic")]
|
||||
public TrafficInfo? Traffic { get; init; }
|
||||
|
||||
[JsonPropertyName("queues")]
|
||||
public IReadOnlyList<QueueHealth> Queues { get; init; } = Array.Empty<QueueHealth>();
|
||||
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTimeOffset LastUpdated { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Burn rate alert information.
|
||||
/// </summary>
|
||||
internal sealed class BurnRateInfo
|
||||
{
|
||||
[JsonPropertyName("current")]
|
||||
public double Current { get; init; }
|
||||
|
||||
[JsonPropertyName("shortWindow")]
|
||||
public double ShortWindow { get; init; } // 5m or 1h window
|
||||
|
||||
[JsonPropertyName("longWindow")]
|
||||
public double LongWindow { get; init; } // 6h or 3d window
|
||||
|
||||
[JsonPropertyName("alertLevel")]
|
||||
public string AlertLevel { get; init; } = "none"; // none, warning, critical
|
||||
|
||||
[JsonPropertyName("threshold2x")]
|
||||
public double Threshold2x { get; init; } = 2.0;
|
||||
|
||||
[JsonPropertyName("threshold14x")]
|
||||
public double Threshold14x { get; init; } = 14.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Latency percentile information.
|
||||
/// </summary>
|
||||
internal sealed class LatencyInfo
|
||||
{
|
||||
[JsonPropertyName("p50")]
|
||||
public double P50Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p95")]
|
||||
public double P95Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p99")]
|
||||
public double P99Ms { get; init; }
|
||||
|
||||
[JsonPropertyName("p95Target")]
|
||||
public double P95TargetMs { get; init; } = 300;
|
||||
|
||||
[JsonPropertyName("breaching")]
|
||||
public bool Breaching { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traffic/throughput information.
|
||||
/// </summary>
|
||||
internal sealed class TrafficInfo
|
||||
{
|
||||
[JsonPropertyName("requestsPerSecond")]
|
||||
public double RequestsPerSecond { get; init; }
|
||||
|
||||
[JsonPropertyName("successRate")]
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
[JsonPropertyName("errorRate")]
|
||||
public double ErrorRate { get; init; }
|
||||
|
||||
[JsonPropertyName("totalRequests")]
|
||||
public long TotalRequests { get; init; }
|
||||
|
||||
[JsonPropertyName("totalErrors")]
|
||||
public long TotalErrors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue health information.
|
||||
/// </summary>
|
||||
internal sealed class QueueHealth
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("depth")]
|
||||
public long Depth { get; init; }
|
||||
|
||||
[JsonPropertyName("depthThreshold")]
|
||||
public long DepthThreshold { get; init; } = 1000;
|
||||
|
||||
[JsonPropertyName("oldestMessageAge")]
|
||||
public TimeSpan OldestMessageAge { get; init; }
|
||||
|
||||
[JsonPropertyName("throughput")]
|
||||
public double Throughput { get; init; }
|
||||
|
||||
[JsonPropertyName("successRate")]
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
[JsonPropertyName("alerting")]
|
||||
public bool Alerting { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide health summary.
|
||||
/// </summary>
|
||||
internal sealed class PlatformHealthSummary
|
||||
{
|
||||
[JsonPropertyName("overallStatus")]
|
||||
public string OverallStatus { get; init; } = "unknown";
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<ServiceHealthStatus> Services { get; init; } = Array.Empty<ServiceHealthStatus>();
|
||||
|
||||
[JsonPropertyName("activeAlerts")]
|
||||
public IReadOnlyList<ActiveAlert> ActiveAlerts { get; init; } = Array.Empty<ActiveAlert>();
|
||||
|
||||
[JsonPropertyName("globalErrorBudget")]
|
||||
public double GlobalErrorBudget { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Active alert information.
|
||||
/// </summary>
|
||||
internal sealed class ActiveAlert
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // burn_rate, latency, error_rate, queue_depth
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "warning"; // warning, critical
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public double Value { get; init; }
|
||||
|
||||
[JsonPropertyName("threshold")]
|
||||
public double Threshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for obs top command.
|
||||
/// </summary>
|
||||
internal sealed class ObsTopRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by service names.
|
||||
/// </summary>
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tenant.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include queue details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeQueues")]
|
||||
public bool IncludeQueues { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Refresh interval in seconds for streaming mode (0 = single fetch).
|
||||
/// </summary>
|
||||
[JsonPropertyName("refreshInterval")]
|
||||
public int RefreshInterval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum alerts to return.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxAlerts")]
|
||||
public int MaxAlerts { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of obs top command.
|
||||
/// </summary>
|
||||
internal sealed class ObsTopResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public PlatformHealthSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Trace and logs models
|
||||
|
||||
/// <summary>
|
||||
/// Request for fetching a trace by ID.
|
||||
/// </summary>
|
||||
internal sealed class ObsTraceRequest
|
||||
{
|
||||
[JsonPropertyName("traceId")]
|
||||
public string TraceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("includeEvidence")]
|
||||
public bool IncludeEvidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace with spans.
|
||||
/// </summary>
|
||||
internal sealed class DistributedTrace
|
||||
{
|
||||
[JsonPropertyName("traceId")]
|
||||
public string TraceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootSpan")]
|
||||
public TraceSpan? RootSpan { get; init; }
|
||||
|
||||
[JsonPropertyName("spans")]
|
||||
public IReadOnlyList<TraceSpan> Spans { get; init; } = Array.Empty<TraceSpan>();
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("startTime")]
|
||||
public DateTimeOffset StartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("endTime")]
|
||||
public DateTimeOffset EndTime { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "ok"; // ok, error
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual span within a trace.
|
||||
/// </summary>
|
||||
internal sealed class TraceSpan
|
||||
{
|
||||
[JsonPropertyName("spanId")]
|
||||
public string SpanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("parentSpanId")]
|
||||
public string? ParentSpanId { get; init; }
|
||||
|
||||
[JsonPropertyName("operationName")]
|
||||
public string OperationName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("serviceName")]
|
||||
public string ServiceName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("startTime")]
|
||||
public DateTimeOffset StartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "ok"; // ok, error
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyDictionary<string, string> Tags { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<SpanLog> Logs { get; init; } = Array.Empty<SpanLog>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log entry within a span.
|
||||
/// </summary>
|
||||
internal sealed class SpanLog
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info"; // debug, info, warn, error
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link to evidence artifact (SBOM, VEX, attestation, etc.).
|
||||
/// </summary>
|
||||
internal sealed class EvidenceLink
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // sbom, vex, attestation, scan_result
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string Uri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching a trace.
|
||||
/// </summary>
|
||||
internal sealed class ObsTraceResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("trace")]
|
||||
public DistributedTrace? Trace { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for fetching logs.
|
||||
/// </summary>
|
||||
internal sealed class ObsLogsRequest
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public IReadOnlyList<string> Services { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("levels")]
|
||||
public IReadOnlyList<string> Levels { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public string? Query { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 100;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log entry from the platform.
|
||||
/// </summary>
|
||||
internal sealed class LogEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info";
|
||||
|
||||
[JsonPropertyName("service")]
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
[JsonPropertyName("spanId")]
|
||||
public string? SpanId { get; init; }
|
||||
|
||||
[JsonPropertyName("fields")]
|
||||
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("evidenceLinks")]
|
||||
public IReadOnlyList<EvidenceLink> EvidenceLinks { get; init; } = Array.Empty<EvidenceLink>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of fetching logs.
|
||||
/// </summary>
|
||||
internal sealed class ObsLogsResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<LogEntry> Logs { get; init; } = Array.Empty<LogEntry>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-OBS-55-001: Incident mode models
|
||||
|
||||
/// <summary>
|
||||
/// Incident mode state.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeState
|
||||
{
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("setAt")]
|
||||
public DateTimeOffset? SetAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("retentionExtensionDays")]
|
||||
public int RetentionExtensionDays { get; init; } = 60;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = "cli"; // cli, config, api
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to enable incident mode.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeEnableRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("ttlMinutes")]
|
||||
public int TtlMinutes { get; init; } = 30;
|
||||
|
||||
[JsonPropertyName("retentionExtensionDays")]
|
||||
public int RetentionExtensionDays { get; init; } = 60;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to disable incident mode.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeDisableRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of incident mode operation.
|
||||
/// </summary>
|
||||
internal sealed class IncidentModeResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public IncidentModeState? State { get; init; }
|
||||
|
||||
[JsonPropertyName("previousState")]
|
||||
public IncidentModeState? PreviousState { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
671
src/Cli/StellaOps.Cli/Services/Models/OrchestratorModels.cs
Normal file
671
src/Cli/StellaOps.Cli/Services/Models/OrchestratorModels.cs
Normal file
@@ -0,0 +1,671 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-ORCH-32-001: Orchestrator source and job models for stella orch commands
|
||||
|
||||
/// <summary>
|
||||
/// Source status values.
|
||||
/// </summary>
|
||||
internal static class SourceStatuses
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Paused = "paused";
|
||||
public const string Disabled = "disabled";
|
||||
public const string Throttled = "throttled";
|
||||
public const string Error = "error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source type values representing data feed categories.
|
||||
/// </summary>
|
||||
internal static class SourceTypes
|
||||
{
|
||||
public const string Advisory = "advisory";
|
||||
public const string Vex = "vex";
|
||||
public const string Sbom = "sbom";
|
||||
public const string Package = "package";
|
||||
public const string Registry = "registry";
|
||||
public const string Custom = "custom";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrator source definition representing a data feed.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorSource
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = SourceTypes.Advisory;
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = SourceStatuses.Active;
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
[JsonPropertyName("schedule")]
|
||||
public SourceSchedule? Schedule { get; init; }
|
||||
|
||||
[JsonPropertyName("rateLimit")]
|
||||
public SourceRateLimit? RateLimit { get; init; }
|
||||
|
||||
[JsonPropertyName("lastRun")]
|
||||
public SourceLastRun? LastRun { get; init; }
|
||||
|
||||
[JsonPropertyName("metrics")]
|
||||
public SourceMetrics? Metrics { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("pausedAt")]
|
||||
public DateTimeOffset? PausedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("pausedBy")]
|
||||
public string? PausedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("pauseReason")]
|
||||
public string? PauseReason { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source schedule configuration.
|
||||
/// </summary>
|
||||
internal sealed class SourceSchedule
|
||||
{
|
||||
[JsonPropertyName("cron")]
|
||||
public string? Cron { get; init; }
|
||||
|
||||
[JsonPropertyName("intervalMinutes")]
|
||||
public int? IntervalMinutes { get; init; }
|
||||
|
||||
[JsonPropertyName("nextRunAt")]
|
||||
public DateTimeOffset? NextRunAt { get; init; }
|
||||
|
||||
[JsonPropertyName("timezone")]
|
||||
public string Timezone { get; init; } = "UTC";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source rate limit configuration.
|
||||
/// </summary>
|
||||
internal sealed class SourceRateLimit
|
||||
{
|
||||
[JsonPropertyName("maxRequestsPerMinute")]
|
||||
public int MaxRequestsPerMinute { get; init; }
|
||||
|
||||
[JsonPropertyName("maxRequestsPerHour")]
|
||||
public int? MaxRequestsPerHour { get; init; }
|
||||
|
||||
[JsonPropertyName("burstSize")]
|
||||
public int BurstSize { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("currentTokens")]
|
||||
public double? CurrentTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("refillRatePerSecond")]
|
||||
public double? RefillRatePerSecond { get; init; }
|
||||
|
||||
[JsonPropertyName("throttledUntil")]
|
||||
public DateTimeOffset? ThrottledUntil { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source last run information.
|
||||
/// </summary>
|
||||
internal sealed class SourceLastRun
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string? RunId { get; init; }
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("itemsProcessed")]
|
||||
public long? ItemsProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("itemsFailed")]
|
||||
public long? ItemsFailed { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMs")]
|
||||
public long? DurationMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source metrics summary.
|
||||
/// </summary>
|
||||
internal sealed class SourceMetrics
|
||||
{
|
||||
[JsonPropertyName("totalRuns")]
|
||||
public long TotalRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("successfulRuns")]
|
||||
public long SuccessfulRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("failedRuns")]
|
||||
public long FailedRuns { get; init; }
|
||||
|
||||
[JsonPropertyName("averageDurationMs")]
|
||||
public double? AverageDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("totalItemsProcessed")]
|
||||
public long TotalItemsProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("totalItemsFailed")]
|
||||
public long TotalItemsFailed { get; init; }
|
||||
|
||||
[JsonPropertyName("lastSuccessAt")]
|
||||
public DateTimeOffset? LastSuccessAt { get; init; }
|
||||
|
||||
[JsonPropertyName("lastFailureAt")]
|
||||
public DateTimeOffset? LastFailureAt { get; init; }
|
||||
|
||||
[JsonPropertyName("uptimePercent")]
|
||||
public double? UptimePercent { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list sources.
|
||||
/// </summary>
|
||||
internal sealed class SourceListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string? Host { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing sources.
|
||||
/// </summary>
|
||||
internal sealed class SourceListResponse
|
||||
{
|
||||
[JsonPropertyName("sources")]
|
||||
public IReadOnlyList<OrchestratorSource> Sources { get; init; } = Array.Empty<OrchestratorSource>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to pause a source.
|
||||
/// </summary>
|
||||
internal sealed class SourcePauseRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("durationMinutes")]
|
||||
public int? DurationMinutes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resume a source.
|
||||
/// </summary>
|
||||
internal sealed class SourceResumeRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test a source connection.
|
||||
/// </summary>
|
||||
internal sealed class SourceTestRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("timeout")]
|
||||
public int TimeoutSeconds { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source operation.
|
||||
/// </summary>
|
||||
internal sealed class SourceOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public OrchestratorSource? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of source test operation.
|
||||
/// </summary>
|
||||
internal sealed class SourceTestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachable")]
|
||||
public bool Reachable { get; init; }
|
||||
|
||||
[JsonPropertyName("latencyMs")]
|
||||
public long? LatencyMs { get; init; }
|
||||
|
||||
[JsonPropertyName("statusCode")]
|
||||
public int? StatusCode { get; init; }
|
||||
|
||||
[JsonPropertyName("errorMessage")]
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("tlsValid")]
|
||||
public bool? TlsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("tlsExpiry")]
|
||||
public DateTimeOffset? TlsExpiry { get; init; }
|
||||
|
||||
[JsonPropertyName("testedAt")]
|
||||
public DateTimeOffset TestedAt { get; init; }
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Backfill wizard and quota management models
|
||||
|
||||
/// <summary>
|
||||
/// Request to start a backfill operation for a source.
|
||||
/// </summary>
|
||||
internal sealed class BackfillRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; } = 5;
|
||||
|
||||
[JsonPropertyName("concurrency")]
|
||||
public int Concurrency { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("batchSize")]
|
||||
public int BatchSize { get; init; } = 100;
|
||||
|
||||
[JsonPropertyName("resume")]
|
||||
public bool Resume { get; init; }
|
||||
|
||||
[JsonPropertyName("filter")]
|
||||
public string? Filter { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a backfill operation.
|
||||
/// </summary>
|
||||
internal sealed class BackfillResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("backfillId")]
|
||||
public string? BackfillId { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("from")]
|
||||
public DateTimeOffset From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public DateTimeOffset To { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("estimatedItems")]
|
||||
public long? EstimatedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("processedItems")]
|
||||
public long ProcessedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("failedItems")]
|
||||
public long FailedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("skippedItems")]
|
||||
public long SkippedItems { get; init; }
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("estimatedDurationMs")]
|
||||
public long? EstimatedDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("actualDurationMs")]
|
||||
public long? ActualDurationMs { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status values for backfill operations.
|
||||
/// </summary>
|
||||
internal static class BackfillStatuses
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Running = "running";
|
||||
public const string Completed = "completed";
|
||||
public const string Failed = "failed";
|
||||
public const string Cancelled = "cancelled";
|
||||
public const string DryRun = "dry_run";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list backfill operations.
|
||||
/// </summary>
|
||||
internal sealed class BackfillListRequest
|
||||
{
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing backfill operations.
|
||||
/// </summary>
|
||||
internal sealed class BackfillListResponse
|
||||
{
|
||||
[JsonPropertyName("backfills")]
|
||||
public IReadOnlyList<BackfillResult> Backfills { get; init; } = Array.Empty<BackfillResult>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to cancel a backfill operation.
|
||||
/// </summary>
|
||||
internal sealed class BackfillCancelRequest
|
||||
{
|
||||
[JsonPropertyName("backfillId")]
|
||||
public string BackfillId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota resource representing usage limits for a tenant/source.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorQuota
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public long Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("used")]
|
||||
public long Used { get; init; }
|
||||
|
||||
[JsonPropertyName("remaining")]
|
||||
public long Remaining { get; init; }
|
||||
|
||||
[JsonPropertyName("period")]
|
||||
public string Period { get; init; } = "monthly";
|
||||
|
||||
[JsonPropertyName("periodStart")]
|
||||
public DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
[JsonPropertyName("periodEnd")]
|
||||
public DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
[JsonPropertyName("resetAt")]
|
||||
public DateTimeOffset ResetAt { get; init; }
|
||||
|
||||
[JsonPropertyName("warningThreshold")]
|
||||
public double WarningThreshold { get; init; } = 0.8;
|
||||
|
||||
[JsonPropertyName("isWarning")]
|
||||
public bool IsWarning { get; init; }
|
||||
|
||||
[JsonPropertyName("isExceeded")]
|
||||
public bool IsExceeded { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota resource types.
|
||||
/// </summary>
|
||||
internal static class QuotaResourceTypes
|
||||
{
|
||||
public const string ApiCalls = "api_calls";
|
||||
public const string DataIngested = "data_ingested_bytes";
|
||||
public const string ItemsProcessed = "items_processed";
|
||||
public const string Backfills = "backfills";
|
||||
public const string ConcurrentJobs = "concurrent_jobs";
|
||||
public const string Storage = "storage_bytes";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quota period types.
|
||||
/// </summary>
|
||||
internal static class QuotaPeriods
|
||||
{
|
||||
public const string Hourly = "hourly";
|
||||
public const string Daily = "daily";
|
||||
public const string Weekly = "weekly";
|
||||
public const string Monthly = "monthly";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get quotas.
|
||||
/// </summary>
|
||||
internal sealed class QuotaGetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string? ResourceType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from getting quotas.
|
||||
/// </summary>
|
||||
internal sealed class QuotaGetResponse
|
||||
{
|
||||
[JsonPropertyName("quotas")]
|
||||
public IReadOnlyList<OrchestratorQuota> Quotas { get; init; } = Array.Empty<OrchestratorQuota>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set a quota limit.
|
||||
/// </summary>
|
||||
internal sealed class QuotaSetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public long Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("period")]
|
||||
public string Period { get; init; } = QuotaPeriods.Monthly;
|
||||
|
||||
[JsonPropertyName("warningThreshold")]
|
||||
public double WarningThreshold { get; init; } = 0.8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a quota operation.
|
||||
/// </summary>
|
||||
internal sealed class QuotaOperationResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("quota")]
|
||||
public OrchestratorQuota? Quota { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to reset a quota's usage counter.
|
||||
/// </summary>
|
||||
internal sealed class QuotaResetRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceId")]
|
||||
public string? SourceId { get; init; }
|
||||
|
||||
[JsonPropertyName("resourceType")]
|
||||
public string ResourceType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
915
src/Cli/StellaOps.Cli/Services/Models/PackModels.cs
Normal file
915
src/Cli/StellaOps.Cli/Services/Models/PackModels.cs
Normal file
@@ -0,0 +1,915 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PACKS-42-001: Task Pack models for stella pack commands
|
||||
|
||||
/// <summary>
|
||||
/// Task pack metadata from the registry.
|
||||
/// </summary>
|
||||
internal sealed class TaskPackInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("author")]
|
||||
public string? Author { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public PackSignature? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string> Labels { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<PackInputSchema> Inputs { get; init; } = Array.Empty<PackInputSchema>();
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyList<PackOutputSchema> Outputs { get; init; } = Array.Empty<PackOutputSchema>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack signature information.
|
||||
/// </summary>
|
||||
internal sealed class PackSignature
|
||||
{
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty; // ecdsa-p256, rsa-pkcs1-sha256, etc.
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("certificate")]
|
||||
public string? Certificate { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack input parameter schema.
|
||||
/// </summary>
|
||||
internal sealed class PackInputSchema
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "string"; // string, number, boolean, array, object
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("required")]
|
||||
public bool Required { get; init; }
|
||||
|
||||
[JsonPropertyName("default")]
|
||||
public object? Default { get; init; }
|
||||
|
||||
[JsonPropertyName("enum")]
|
||||
public IReadOnlyList<string>? Enum { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack output schema.
|
||||
/// </summary>
|
||||
internal sealed class PackOutputSchema
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = "string";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to plan a pack execution.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool DryRun { get; init; }
|
||||
|
||||
[JsonPropertyName("validateOnly")]
|
||||
public bool ValidateOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execution plan step.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanStep
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dependsOn")]
|
||||
public IReadOnlyList<string> DependsOn { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("condition")]
|
||||
public string? Condition { get; init; }
|
||||
|
||||
[JsonPropertyName("timeout")]
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
|
||||
[JsonPropertyName("retryPolicy")]
|
||||
public PackRetryPolicy? RetryPolicy { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("requiresApproval")]
|
||||
public bool RequiresApproval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry policy for pack steps.
|
||||
/// </summary>
|
||||
internal sealed class PackRetryPolicy
|
||||
{
|
||||
[JsonPropertyName("maxAttempts")]
|
||||
public int MaxAttempts { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("backoffMultiplier")]
|
||||
public double BackoffMultiplier { get; init; } = 2.0;
|
||||
|
||||
[JsonPropertyName("initialDelayMs")]
|
||||
public int InitialDelayMs { get; init; } = 1000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack plan operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPlanResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("planId")]
|
||||
public string? PlanId { get; init; }
|
||||
|
||||
[JsonPropertyName("planHash")]
|
||||
public string? PlanHash { get; init; }
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public IReadOnlyList<PackPlanStep> Steps { get; init; } = Array.Empty<PackPlanStep>();
|
||||
|
||||
[JsonPropertyName("requiresApproval")]
|
||||
public bool RequiresApproval { get; init; }
|
||||
|
||||
[JsonPropertyName("approvalGates")]
|
||||
public IReadOnlyList<string> ApprovalGates { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("estimatedDuration")]
|
||||
public TimeSpan? EstimatedDuration { get; init; }
|
||||
|
||||
[JsonPropertyName("validationErrors")]
|
||||
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validation error from pack plan/verify.
|
||||
/// </summary>
|
||||
internal sealed class PackValidationError
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string? Path { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "error"; // error, warning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to run a pack.
|
||||
/// </summary>
|
||||
internal sealed class PackRunRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("planId")]
|
||||
public string? PlanId { get; init; }
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyDictionary<string, object>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("waitForCompletion")]
|
||||
public bool WaitForCompletion { get; init; }
|
||||
|
||||
[JsonPropertyName("timeoutMinutes")]
|
||||
public int TimeoutMinutes { get; init; } = 60;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack run status.
|
||||
/// </summary>
|
||||
internal sealed class PackRunStatus
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, cancelled, waiting_approval
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("currentStep")]
|
||||
public string? CurrentStep { get; init; }
|
||||
|
||||
[JsonPropertyName("stepStatuses")]
|
||||
public IReadOnlyList<PackStepStatus> StepStatuses { get; init; } = Array.Empty<PackStepStatus>();
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
|
||||
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<PackArtifact> Artifacts { get; init; } = Array.Empty<PackArtifact>();
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of individual pack step.
|
||||
/// </summary>
|
||||
internal sealed class PackStepStatus
|
||||
{
|
||||
[JsonPropertyName("stepId")]
|
||||
public string StepId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "pending"; // pending, running, succeeded, failed, skipped
|
||||
|
||||
[JsonPropertyName("startedAt")]
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
|
||||
[JsonPropertyName("attempt")]
|
||||
public int Attempt { get; init; } = 1;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("outputs")]
|
||||
public IReadOnlyDictionary<string, object>? Outputs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact produced by pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifact
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // log, sbom, report, attestation
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack run operation.
|
||||
/// </summary>
|
||||
internal sealed class PackRunResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("run")]
|
||||
public PackRunStatus? Run { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to push a pack to the registry.
|
||||
/// </summary>
|
||||
internal sealed class PackPushRequest
|
||||
{
|
||||
[JsonPropertyName("packPath")]
|
||||
public string PackPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sign")]
|
||||
public bool Sign { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack push operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPushResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to pull a pack from the registry.
|
||||
/// </summary>
|
||||
internal sealed class PackPullRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("verify")]
|
||||
public bool Verify { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack pull operation.
|
||||
/// </summary>
|
||||
internal sealed class PackPullResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a pack.
|
||||
/// </summary>
|
||||
internal sealed class PackVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("packPath")]
|
||||
public string? PackPath { get; init; }
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("checkRekor")]
|
||||
public bool CheckRekor { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("checkExpiry")]
|
||||
public bool CheckExpiry { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack verify operation.
|
||||
/// </summary>
|
||||
internal sealed class PackVerifyResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("pack")]
|
||||
public TaskPackInfo? Pack { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("digestMatch")]
|
||||
public bool DigestMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerified")]
|
||||
public bool? RekorVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateValid")]
|
||||
public bool? CertificateValid { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateExpiry")]
|
||||
public DateTimeOffset? CertificateExpiry { get; init; }
|
||||
|
||||
[JsonPropertyName("schemaValid")]
|
||||
public bool SchemaValid { get; init; }
|
||||
|
||||
[JsonPropertyName("validationErrors")]
|
||||
public IReadOnlyList<PackValidationError> ValidationErrors { get; init; } = Array.Empty<PackValidationError>();
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-PACKS-43-001: Advanced pack features
|
||||
|
||||
/// <summary>
|
||||
/// Request to pause a pack run waiting for approval.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalPauseRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resume a paused pack run with approval.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalResumeRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("approved")]
|
||||
public bool Approved { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an approval operation.
|
||||
/// </summary>
|
||||
internal sealed class PackApprovalResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("run")]
|
||||
public PackRunStatus? Run { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to inject a secret into a pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackSecretInjectRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("secretProvider")]
|
||||
public string SecretProvider { get; init; } = "vault"; // vault, aws-ssm, azure-keyvault, k8s-secret
|
||||
|
||||
[JsonPropertyName("targetEnvVar")]
|
||||
public string? TargetEnvVar { get; init; }
|
||||
|
||||
[JsonPropertyName("targetPath")]
|
||||
public string? TargetPath { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a secret injection operation.
|
||||
/// </summary>
|
||||
internal sealed class PackSecretInjectResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("secretRef")]
|
||||
public string SecretRef { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("injectedAt")]
|
||||
public DateTimeOffset InjectedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("auditEventId")]
|
||||
public string? AuditEventId { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list pack runs.
|
||||
/// </summary>
|
||||
internal sealed class PackRunListRequest
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
[JsonPropertyName("until")]
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
[JsonPropertyName("pageSize")]
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
[JsonPropertyName("pageToken")]
|
||||
public string? PageToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from listing pack runs.
|
||||
/// </summary>
|
||||
internal sealed class PackRunListResponse
|
||||
{
|
||||
[JsonPropertyName("runs")]
|
||||
public IReadOnlyList<PackRunStatus> Runs { get; init; } = Array.Empty<PackRunStatus>();
|
||||
|
||||
[JsonPropertyName("nextPageToken")]
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
[JsonPropertyName("totalCount")]
|
||||
public long? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to cancel a pack run.
|
||||
/// </summary>
|
||||
internal sealed class PackCancelRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("force")]
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to get pack run logs.
|
||||
/// </summary>
|
||||
internal sealed class PackLogsRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
|
||||
[JsonPropertyName("follow")]
|
||||
public bool Follow { get; init; }
|
||||
|
||||
[JsonPropertyName("tail")]
|
||||
public int? Tail { get; init; }
|
||||
|
||||
[JsonPropertyName("since")]
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack log entry.
|
||||
/// </summary>
|
||||
internal sealed class PackLogEntry
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("stepId")]
|
||||
public string? StepId { get; init; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = "info"; // debug, info, warn, error
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public string Stream { get; init; } = "stdout"; // stdout, stderr
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of pack logs request.
|
||||
/// </summary>
|
||||
internal sealed class PackLogsResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("logs")]
|
||||
public IReadOnlyList<PackLogEntry> Logs { get; init; } = Array.Empty<PackLogEntry>();
|
||||
|
||||
[JsonPropertyName("nextToken")]
|
||||
public string? NextToken { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to download a pack artifact.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifactDownloadRequest
|
||||
{
|
||||
[JsonPropertyName("runId")]
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactName")]
|
||||
public string ArtifactName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of artifact download.
|
||||
/// </summary>
|
||||
internal sealed class PackArtifactDownloadResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact")]
|
||||
public PackArtifact? Artifact { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offline cache entry for packs.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheEntry
|
||||
{
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cachedAt")]
|
||||
public DateTimeOffset CachedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to manage offline cache.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheRequest
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string Action { get; init; } = "list"; // list, add, remove, sync, prune
|
||||
|
||||
[JsonPropertyName("packId")]
|
||||
public string? PackId { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("cacheDir")]
|
||||
public string? CacheDir { get; init; }
|
||||
|
||||
[JsonPropertyName("maxAge")]
|
||||
public TimeSpan? MaxAge { get; init; }
|
||||
|
||||
[JsonPropertyName("maxSize")]
|
||||
public long? MaxSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of cache operation.
|
||||
/// </summary>
|
||||
internal sealed class PackCacheResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<PackCacheEntry> Entries { get; init; } = Array.Empty<PackCacheEntry>();
|
||||
|
||||
[JsonPropertyName("totalSize")]
|
||||
public long TotalSize { get; init; }
|
||||
|
||||
[JsonPropertyName("prunedCount")]
|
||||
public int PrunedCount { get; init; }
|
||||
|
||||
[JsonPropertyName("prunedSize")]
|
||||
public long PrunedSize { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -2,16 +2,39 @@ using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation modes
|
||||
internal enum PolicySimulationMode
|
||||
{
|
||||
Quick,
|
||||
Batch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input for policy simulation.
|
||||
/// Per CLI-EXC-25-002, supports exception preview via WithExceptions/WithoutExceptions.
|
||||
/// Per CLI-POLICY-27-003, supports mode (quick/batch), SBOM selectors, heatmap, and manifest download.
|
||||
/// Per CLI-SIG-26-002, supports reachability overrides for vulnerability/package state and score.
|
||||
/// </summary>
|
||||
internal sealed record PolicySimulationInput(
|
||||
int? BaseVersion,
|
||||
int? CandidateVersion,
|
||||
IReadOnlyList<string> SbomSet,
|
||||
IReadOnlyDictionary<string, object?> Environment,
|
||||
bool Explain);
|
||||
bool Explain,
|
||||
IReadOnlyList<string>? WithExceptions = null,
|
||||
IReadOnlyList<string>? WithoutExceptions = null,
|
||||
PolicySimulationMode? Mode = null,
|
||||
IReadOnlyList<string>? SbomSelectors = null,
|
||||
bool IncludeHeatmap = false,
|
||||
bool IncludeManifest = false,
|
||||
IReadOnlyList<ReachabilityOverride>? ReachabilityOverrides = null);
|
||||
|
||||
internal sealed record PolicySimulationResult(
|
||||
PolicySimulationDiff Diff,
|
||||
string? ExplainUri);
|
||||
string? ExplainUri,
|
||||
PolicySimulationHeatmap? Heatmap = null,
|
||||
string? ManifestDownloadUri = null,
|
||||
string? ManifestDigest = null);
|
||||
|
||||
internal sealed record PolicySimulationDiff(
|
||||
string? SchemaVersion,
|
||||
@@ -24,3 +47,17 @@ internal sealed record PolicySimulationDiff(
|
||||
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
|
||||
|
||||
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap summary for quick severity visualization
|
||||
internal sealed record PolicySimulationHeatmap(
|
||||
int Critical,
|
||||
int High,
|
||||
int Medium,
|
||||
int Low,
|
||||
int Info,
|
||||
IReadOnlyList<PolicySimulationHeatmapBucket> Buckets);
|
||||
|
||||
internal sealed record PolicySimulationHeatmapBucket(
|
||||
string Label,
|
||||
int Count,
|
||||
string? Color);
|
||||
|
||||
1211
src/Cli/StellaOps.Cli/Services/Models/PolicyWorkspaceModels.cs
Normal file
1211
src/Cli/StellaOps.Cli/Services/Models/PolicyWorkspaceModels.cs
Normal file
File diff suppressed because it is too large
Load Diff
468
src/Cli/StellaOps.Cli/Services/Models/PromotionModels.cs
Normal file
468
src/Cli/StellaOps.Cli/Services/Models/PromotionModels.cs
Normal file
@@ -0,0 +1,468 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PROMO-70-001: Promotion attestation models
|
||||
|
||||
/// <summary>
|
||||
/// Request for assembling a promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAssembleRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public string Image { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("vexPath")]
|
||||
public string? VexPath { get; init; }
|
||||
|
||||
[JsonPropertyName("fromEnvironment")]
|
||||
public string FromEnvironment { get; init; } = "staging";
|
||||
|
||||
[JsonPropertyName("toEnvironment")]
|
||||
public string ToEnvironment { get; init; } = "prod";
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("pipeline")]
|
||||
public string? Pipeline { get; init; }
|
||||
|
||||
[JsonPropertyName("ticket")]
|
||||
public string? Ticket { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
|
||||
[JsonPropertyName("skipRekor")]
|
||||
public bool SkipRekor { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion attestation predicate following stella.ops/promotion@v1 schema.
|
||||
/// </summary>
|
||||
internal sealed class PromotionPredicate
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = "stella.ops/promotion@v1";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public IReadOnlyList<PromotionSubject> Subject { get; init; } = Array.Empty<PromotionSubject>();
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
|
||||
|
||||
[JsonPropertyName("promotion")]
|
||||
public PromotionMetadata Promotion { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public PromotionRekorEntry? Rekor { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation")]
|
||||
public PromotionAttestationMetadata? Attestation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject in promotion attestation (image reference).
|
||||
/// </summary>
|
||||
internal sealed class PromotionSubject
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public IReadOnlyDictionary<string, string> Digest { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material in promotion attestation (SBOM, VEX, etc.).
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterial
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("algo")]
|
||||
public string Algo { get; init; } = "sha256";
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("uri")]
|
||||
public string? Uri { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotion metadata.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMetadata
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public string From { get; init; } = "staging";
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public string To { get; init; } = "prod";
|
||||
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("pipeline")]
|
||||
public string? Pipeline { get; init; }
|
||||
|
||||
[JsonPropertyName("ticket")]
|
||||
public string? Ticket { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor entry in promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionRekorEntry
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public string Uuid { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusionProof")]
|
||||
public PromotionInclusionProof? InclusionProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle inclusion proof.
|
||||
/// </summary>
|
||||
internal sealed class PromotionInclusionProof
|
||||
{
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyList<string> Hashes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public long TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public PromotionCheckpoint? Checkpoint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor checkpoint.
|
||||
/// </summary>
|
||||
internal sealed class PromotionCheckpoint
|
||||
{
|
||||
[JsonPropertyName("origin")]
|
||||
public string Origin { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signedNote")]
|
||||
public string? SignedNote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation metadata.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestationMetadata
|
||||
{
|
||||
[JsonPropertyName("bundle_sha256")]
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("witness")]
|
||||
public string? Witness { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion assemble operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAssembleResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterial> Materials { get; init; } = Array.Empty<PromotionMaterial>();
|
||||
|
||||
[JsonPropertyName("rekorEntry")]
|
||||
public PromotionRekorEntry? RekorEntry { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// CLI-PROMO-70-002: Promotion attest/verify models
|
||||
|
||||
/// <summary>
|
||||
/// Request for attesting a promotion predicate.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("predicatePath")]
|
||||
public string? PredicatePath { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("useKeyless")]
|
||||
public bool UseKeyless { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("uploadToRekor")]
|
||||
public bool UploadToRekor { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion attest operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionAttestResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorEntry")]
|
||||
public PromotionRekorEntry? RekorEntry { get; init; }
|
||||
|
||||
[JsonPropertyName("auditId")]
|
||||
public string? AuditId { get; init; }
|
||||
|
||||
[JsonPropertyName("signerKeyId")]
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelope
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = "application/vnd.in-toto+json";
|
||||
|
||||
[JsonPropertyName("payload")]
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<DsseSignature> Signatures { get; init; } = Array.Empty<DsseSignature>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string Sig { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cert")]
|
||||
public string? Cert { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for verifying a promotion attestation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string? BundlePath { get; init; }
|
||||
|
||||
[JsonPropertyName("predicatePath")]
|
||||
public string? PredicatePath { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("vexPath")]
|
||||
public string? VexPath { get; init; }
|
||||
|
||||
[JsonPropertyName("trustRootPath")]
|
||||
public string? TrustRootPath { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpointPath")]
|
||||
public string? CheckpointPath { get; init; }
|
||||
|
||||
[JsonPropertyName("skipRekorVerification")]
|
||||
public bool SkipRekorVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("skipSignatureVerification")]
|
||||
public bool SkipSignatureVerification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of promotion verify operation.
|
||||
/// </summary>
|
||||
internal sealed class PromotionVerifyResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureVerification")]
|
||||
public PromotionSignatureVerification? SignatureVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("materialVerification")]
|
||||
public PromotionMaterialVerification? MaterialVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerification")]
|
||||
public PromotionRekorVerification? RekorVerification { get; init; }
|
||||
|
||||
[JsonPropertyName("predicate")]
|
||||
public PromotionPredicate? Predicate { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionSignatureVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("certSubject")]
|
||||
public string? CertSubject { get; init; }
|
||||
|
||||
[JsonPropertyName("certIssuer")]
|
||||
public string? CertIssuer { get; init; }
|
||||
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTimeOffset? ValidFrom { get; init; }
|
||||
|
||||
[JsonPropertyName("validTo")]
|
||||
public DateTimeOffset? ValidTo { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterialVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("materials")]
|
||||
public IReadOnlyList<PromotionMaterialVerificationEntry> Materials { get; init; } = Array.Empty<PromotionMaterialVerificationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual material verification entry.
|
||||
/// </summary>
|
||||
internal sealed class PromotionMaterialVerificationEntry
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedDigest")]
|
||||
public string ExpectedDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("actualDigest")]
|
||||
public string? ActualDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor verification result.
|
||||
/// </summary>
|
||||
internal sealed class PromotionRekorVerification
|
||||
{
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; init; }
|
||||
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
[JsonPropertyName("logIndex")]
|
||||
public long? LogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusionProofVerified")]
|
||||
public bool InclusionProofVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("checkpointVerified")]
|
||||
public bool CheckpointVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
252
src/Cli/StellaOps.Cli/Services/Models/ReachabilityModels.cs
Normal file
252
src/Cli/StellaOps.Cli/Services/Models/ReachabilityModels.cs
Normal file
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-SIG-26-001: Reachability command models
|
||||
|
||||
/// <summary>
|
||||
/// Request to upload a call graph for reachability analysis.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityUploadCallGraphRequest
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphPath")]
|
||||
public string CallGraphPath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of uploading a call graph.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityUploadCallGraphResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphId")]
|
||||
public string CallGraphId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("entriesProcessed")]
|
||||
public int EntriesProcessed { get; init; }
|
||||
|
||||
[JsonPropertyName("errorsCount")]
|
||||
public int ErrorsCount { get; init; }
|
||||
|
||||
[JsonPropertyName("uploadedAt")]
|
||||
public DateTimeOffset UploadedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list reachability analyses.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityListRequest
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing reachability analyses.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityListResponse
|
||||
{
|
||||
[JsonPropertyName("analyses")]
|
||||
public IReadOnlyList<ReachabilityAnalysisSummary> Analyses { get; init; } = Array.Empty<ReachabilityAnalysisSummary>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a reachability analysis.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityAnalysisSummary
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("assetName")]
|
||||
public string? AssetName { get; init; }
|
||||
|
||||
[JsonPropertyName("callGraphId")]
|
||||
public string CallGraphId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachableCount")]
|
||||
public int ReachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("unreachableCount")]
|
||||
public int UnreachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("unknownCount")]
|
||||
public int UnknownCount { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("completedAt")]
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to explain reachability for a specific vulnerability or package.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityExplainRequest
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("includeCallPaths")]
|
||||
public bool IncludeCallPaths { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of reachability explanation.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityExplainResult
|
||||
{
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("reachabilityState")]
|
||||
public string ReachabilityState { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reachabilityScore")]
|
||||
public double? ReachabilityScore { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public string Confidence { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reasoning")]
|
||||
public string? Reasoning { get; init; }
|
||||
|
||||
[JsonPropertyName("callPaths")]
|
||||
public IReadOnlyList<ReachabilityCallPath> CallPaths { get; init; } = Array.Empty<ReachabilityCallPath>();
|
||||
|
||||
[JsonPropertyName("affectedFunctions")]
|
||||
public IReadOnlyList<ReachabilityFunction> AffectedFunctions { get; init; } = Array.Empty<ReachabilityFunction>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call path demonstrating reachability.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityCallPath
|
||||
{
|
||||
[JsonPropertyName("pathId")]
|
||||
public string PathId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("depth")]
|
||||
public int Depth { get; init; }
|
||||
|
||||
[JsonPropertyName("entryPoint")]
|
||||
public ReachabilityFunction EntryPoint { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("frames")]
|
||||
public IReadOnlyList<ReachabilityFunction> Frames { get; init; } = Array.Empty<ReachabilityFunction>();
|
||||
|
||||
[JsonPropertyName("vulnerableFunction")]
|
||||
public ReachabilityFunction VulnerableFunction { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function in the call graph.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityFunction
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("className")]
|
||||
public string? ClassName { get; init; }
|
||||
|
||||
[JsonPropertyName("packageName")]
|
||||
public string? PackageName { get; init; }
|
||||
|
||||
[JsonPropertyName("filePath")]
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
[JsonPropertyName("lineNumber")]
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
|
||||
// CLI-SIG-26-002: Policy simulate reachability override models (extends PolicySimulationInput)
|
||||
|
||||
/// <summary>
|
||||
/// Reachability override for policy simulation.
|
||||
/// </summary>
|
||||
internal sealed class ReachabilityOverride
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string State { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
}
|
||||
448
src/Cli/StellaOps.Cli/Services/Models/RiskModels.cs
Normal file
448
src/Cli/StellaOps.Cli/Services/Models/RiskModels.cs
Normal file
@@ -0,0 +1,448 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-RISK-66-001: Risk profile list models
|
||||
|
||||
/// <summary>
|
||||
/// Request to list risk profiles.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileListRequest
|
||||
{
|
||||
[JsonPropertyName("includeDisabled")]
|
||||
public bool IncludeDisabled { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public string? Category { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing a list of risk profiles.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileListResponse
|
||||
{
|
||||
[JsonPropertyName("profiles")]
|
||||
public IReadOnlyList<RiskProfileSummary> Profiles { get; init; } = Array.Empty<RiskProfileSummary>();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a risk profile.
|
||||
/// </summary>
|
||||
internal sealed class RiskProfileSummary
|
||||
{
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("category")]
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; init; }
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
[JsonPropertyName("builtIn")]
|
||||
public bool BuiltIn { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleCount")]
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
[JsonPropertyName("severityWeights")]
|
||||
public RiskSeverityWeights? SeverityWeights { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity weights for risk scoring.
|
||||
/// </summary>
|
||||
internal sealed class RiskSeverityWeights
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
public double Critical { get; init; }
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
public double High { get; init; }
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
public double Medium { get; init; }
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
public double Low { get; init; }
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
public double Info { get; init; }
|
||||
}
|
||||
|
||||
// CLI-RISK-66-002: Risk simulate models
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate risk scoring.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateRequest
|
||||
{
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("diffMode")]
|
||||
public bool DiffMode { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineProfileId")]
|
||||
public string? BaselineProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of risk simulation.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileName")]
|
||||
public string ProfileName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("overallScore")]
|
||||
public double OverallScore { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findings")]
|
||||
public RiskSimulateFindingsSummary Findings { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("componentScores")]
|
||||
public IReadOnlyList<RiskComponentScore> ComponentScores { get; init; } = Array.Empty<RiskComponentScore>();
|
||||
|
||||
[JsonPropertyName("diff")]
|
||||
public RiskSimulateDiff? Diff { get; init; }
|
||||
|
||||
[JsonPropertyName("simulatedAt")]
|
||||
public DateTimeOffset SimulatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of findings from risk simulation.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateFindingsSummary
|
||||
{
|
||||
[JsonPropertyName("critical")]
|
||||
public int Critical { get; init; }
|
||||
|
||||
[JsonPropertyName("high")]
|
||||
public int High { get; init; }
|
||||
|
||||
[JsonPropertyName("medium")]
|
||||
public int Medium { get; init; }
|
||||
|
||||
[JsonPropertyName("low")]
|
||||
public int Low { get; init; }
|
||||
|
||||
[JsonPropertyName("info")]
|
||||
public int Info { get; init; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component-level risk score.
|
||||
/// </summary>
|
||||
internal sealed class RiskComponentScore
|
||||
{
|
||||
[JsonPropertyName("componentId")]
|
||||
public string ComponentId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentName")]
|
||||
public string ComponentName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findingCount")]
|
||||
public int FindingCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diff between baseline and candidate risk scores.
|
||||
/// </summary>
|
||||
internal sealed class RiskSimulateDiff
|
||||
{
|
||||
[JsonPropertyName("baselineScore")]
|
||||
public double BaselineScore { get; init; }
|
||||
|
||||
[JsonPropertyName("candidateScore")]
|
||||
public double CandidateScore { get; init; }
|
||||
|
||||
[JsonPropertyName("delta")]
|
||||
public double Delta { get; init; }
|
||||
|
||||
[JsonPropertyName("improved")]
|
||||
public bool Improved { get; init; }
|
||||
|
||||
[JsonPropertyName("findingsAdded")]
|
||||
public int FindingsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("findingsRemoved")]
|
||||
public int FindingsRemoved { get; init; }
|
||||
}
|
||||
|
||||
// CLI-RISK-67-001: Risk results models
|
||||
|
||||
/// <summary>
|
||||
/// Request to get risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsRequest
|
||||
{
|
||||
[JsonPropertyName("assetId")]
|
||||
public string? AssetId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
[JsonPropertyName("minSeverity")]
|
||||
public string? MinSeverity { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double? MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("includeExplain")]
|
||||
public bool IncludeExplain { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsResponse
|
||||
{
|
||||
[JsonPropertyName("results")]
|
||||
public IReadOnlyList<RiskResult> Results { get; init; } = Array.Empty<RiskResult>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public RiskResultsSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual risk result.
|
||||
/// </summary>
|
||||
internal sealed class RiskResult
|
||||
{
|
||||
[JsonPropertyName("resultId")]
|
||||
public string ResultId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("assetId")]
|
||||
public string AssetId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("assetName")]
|
||||
public string? AssetName { get; init; }
|
||||
|
||||
[JsonPropertyName("profileId")]
|
||||
public string ProfileId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileName")]
|
||||
public string? ProfileName { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("grade")]
|
||||
public string Grade { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("findingCount")]
|
||||
public int FindingCount { get; init; }
|
||||
|
||||
[JsonPropertyName("evaluatedAt")]
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public RiskResultExplain? Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explanation for a risk result.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultExplain
|
||||
{
|
||||
[JsonPropertyName("factors")]
|
||||
public IReadOnlyList<RiskFactor> Factors { get; init; } = Array.Empty<RiskFactor>();
|
||||
|
||||
[JsonPropertyName("recommendations")]
|
||||
public IReadOnlyList<string> Recommendations { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factor contributing to risk score.
|
||||
/// </summary>
|
||||
internal sealed class RiskFactor
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("weight")]
|
||||
public double Weight { get; init; }
|
||||
|
||||
[JsonPropertyName("contribution")]
|
||||
public double Contribution { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of risk results.
|
||||
/// </summary>
|
||||
internal sealed class RiskResultsSummary
|
||||
{
|
||||
[JsonPropertyName("averageScore")]
|
||||
public double AverageScore { get; init; }
|
||||
|
||||
[JsonPropertyName("minScore")]
|
||||
public double MinScore { get; init; }
|
||||
|
||||
[JsonPropertyName("maxScore")]
|
||||
public double MaxScore { get; init; }
|
||||
|
||||
[JsonPropertyName("assetCount")]
|
||||
public int AssetCount { get; init; }
|
||||
|
||||
[JsonPropertyName("bySeverity")]
|
||||
public RiskSimulateFindingsSummary BySeverity { get; init; } = new();
|
||||
}
|
||||
|
||||
// CLI-RISK-68-001: Risk bundle verify models
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a risk bundle.
|
||||
/// </summary>
|
||||
internal sealed class RiskBundleVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("bundlePath")]
|
||||
public string BundlePath { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signaturePath")]
|
||||
public string? SignaturePath { get; init; }
|
||||
|
||||
[JsonPropertyName("checkRekor")]
|
||||
public bool CheckRekor { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a risk bundle.
|
||||
/// </summary>
|
||||
internal sealed class RiskBundleVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string BundleId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bundleVersion")]
|
||||
public string BundleVersion { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("profileCount")]
|
||||
public int ProfileCount { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleCount")]
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signedBy")]
|
||||
public string? SignedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorVerified")]
|
||||
public bool? RekorVerified { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public string? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
633
src/Cli/StellaOps.Cli/Services/Models/SbomModels.cs
Normal file
633
src/Cli/StellaOps.Cli/Services/Models/SbomModels.cs
Normal file
@@ -0,0 +1,633 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-PARITY-41-001: SBOM Explorer models for CLI
|
||||
|
||||
/// <summary>
|
||||
/// SBOM list request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAfter")]
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
[JsonPropertyName("createdBefore")]
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
[JsonPropertyName("hasVulnerabilities")]
|
||||
public bool? HasVulnerabilities { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int? Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated SBOM list response.
|
||||
/// </summary>
|
||||
internal sealed class SbomListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomSummary> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary view of an SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomSummary
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityCount")]
|
||||
public int VulnerabilityCount { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesDetected")]
|
||||
public int LicensesDetected { get; init; }
|
||||
|
||||
[JsonPropertyName("determinismScore")]
|
||||
public double? DeterminismScore { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed SBOM response including components, vulnerabilities, and metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomDetailResponse
|
||||
{
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityCount")]
|
||||
public int VulnerabilityCount { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesDetected")]
|
||||
public int LicensesDetected { get; init; }
|
||||
|
||||
[JsonPropertyName("determinismScore")]
|
||||
public double? DeterminismScore { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<SbomComponent>? Components { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public SbomMetadata? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilities")]
|
||||
public IReadOnlyList<SbomVulnerability>? Vulnerabilities { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public IReadOnlyList<SbomLicense>? Licenses { get; init; }
|
||||
|
||||
[JsonPropertyName("attestation")]
|
||||
public SbomAttestation? Attestation { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public SbomExplainInfo? Explain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM component information.
|
||||
/// </summary>
|
||||
internal sealed class SbomComponent
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("supplier")]
|
||||
public string? Supplier { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public IReadOnlyList<string>? Licenses { get; init; }
|
||||
|
||||
[JsonPropertyName("hashes")]
|
||||
public IReadOnlyDictionary<string, string>? Hashes { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("dependencies")]
|
||||
public IReadOnlyList<string>? Dependencies { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM creation metadata.
|
||||
/// </summary>
|
||||
internal sealed class SbomMetadata
|
||||
{
|
||||
[JsonPropertyName("toolName")]
|
||||
public string? ToolName { get; init; }
|
||||
|
||||
[JsonPropertyName("toolVersion")]
|
||||
public string? ToolVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("scannerVersion")]
|
||||
public string? ScannerVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("serialNumber")]
|
||||
public string? SerialNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("documentNamespace")]
|
||||
public string? DocumentNamespace { get; init; }
|
||||
|
||||
[JsonPropertyName("creators")]
|
||||
public IReadOnlyList<string>? Creators { get; init; }
|
||||
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability found in SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomVulnerability
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public string? AffectedComponent { get; init; }
|
||||
|
||||
[JsonPropertyName("fixedIn")]
|
||||
public string? FixedIn { get; init; }
|
||||
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License information in SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomLicense
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<string>? Components { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation information for SBOM.
|
||||
/// </summary>
|
||||
internal sealed class SbomAttestation
|
||||
{
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogIndex")]
|
||||
public long? RekorLogIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("rekorLogId")]
|
||||
public string? RekorLogId { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateIssuer")]
|
||||
public string? CertificateIssuer { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateSubject")]
|
||||
public string? CertificateSubject { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explain information for SBOM generation (determinism debugging).
|
||||
/// </summary>
|
||||
internal sealed class SbomExplainInfo
|
||||
{
|
||||
[JsonPropertyName("determinismFactors")]
|
||||
public IReadOnlyList<SbomDeterminismFactor>? DeterminismFactors { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public IReadOnlyList<SbomCompositionStep>? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factor affecting SBOM determinism score.
|
||||
/// </summary>
|
||||
internal sealed class SbomDeterminismFactor
|
||||
{
|
||||
[JsonPropertyName("factor")]
|
||||
public string Factor { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("impact")]
|
||||
public string Impact { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double Score { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step in SBOM composition chain.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompositionStep
|
||||
{
|
||||
[JsonPropertyName("step")]
|
||||
public int Step { get; init; }
|
||||
|
||||
[JsonPropertyName("operation")]
|
||||
public string Operation { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("input")]
|
||||
public string? Input { get; init; }
|
||||
|
||||
[JsonPropertyName("output")]
|
||||
public string? Output { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM compare request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("baseSbomId")]
|
||||
public string BaseSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("targetSbomId")]
|
||||
public string TargetSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("includeUnchanged")]
|
||||
public bool IncludeUnchanged { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM comparison result.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareResponse
|
||||
{
|
||||
[JsonPropertyName("baseSbomId")]
|
||||
public string BaseSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("targetSbomId")]
|
||||
public string TargetSbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public SbomCompareSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("componentChanges")]
|
||||
public IReadOnlyList<SbomComponentChange>? ComponentChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilityChanges")]
|
||||
public IReadOnlyList<SbomVulnerabilityChange>? VulnerabilityChanges { get; init; }
|
||||
|
||||
[JsonPropertyName("licenseChanges")]
|
||||
public IReadOnlyList<SbomLicenseChange>? LicenseChanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of SBOM comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomCompareSummary
|
||||
{
|
||||
[JsonPropertyName("componentsAdded")]
|
||||
public int ComponentsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsRemoved")]
|
||||
public int ComponentsRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsModified")]
|
||||
public int ComponentsModified { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsUnchanged")]
|
||||
public int ComponentsUnchanged { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilitiesAdded")]
|
||||
public int VulnerabilitiesAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerabilitiesRemoved")]
|
||||
public int VulnerabilitiesRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesAdded")]
|
||||
public int LicensesAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("licensesRemoved")]
|
||||
public int LicensesRemoved { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomComponentChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentName")]
|
||||
public string ComponentName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("baseVersion")]
|
||||
public string? BaseVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("targetVersion")]
|
||||
public string? TargetVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("basePurl")]
|
||||
public string? BasePurl { get; init; }
|
||||
|
||||
[JsonPropertyName("targetPurl")]
|
||||
public string? TargetPurl { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public IReadOnlyList<string>? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomVulnerabilityChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedComponent")]
|
||||
public string? AffectedComponent { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// License change in comparison.
|
||||
/// </summary>
|
||||
internal sealed class SbomLicenseChange
|
||||
{
|
||||
[JsonPropertyName("changeType")]
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("licenseId")]
|
||||
public string? LicenseId { get; init; }
|
||||
|
||||
[JsonPropertyName("licenseName")]
|
||||
public string LicenseName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM export request parameters.
|
||||
/// </summary>
|
||||
internal sealed class SbomExportRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "spdx";
|
||||
|
||||
[JsonPropertyName("formatVersion")]
|
||||
public string? FormatVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("includeVex")]
|
||||
public bool IncludeVex { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM export result.
|
||||
/// </summary>
|
||||
internal sealed class SbomExportResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("exportId")]
|
||||
public string? ExportId { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("digestAlgorithm")]
|
||||
public string? DigestAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureKeyId")]
|
||||
public string? SignatureKeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
}
|
||||
|
||||
// CLI-PARITY-41-001: Parity matrix models
|
||||
|
||||
/// <summary>
|
||||
/// Parity matrix entry showing CLI command coverage.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixEntry
|
||||
{
|
||||
[JsonPropertyName("commandGroup")]
|
||||
public string CommandGroup { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("command")]
|
||||
public string Command { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("cliSupport")]
|
||||
public string CliSupport { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("apiEndpoint")]
|
||||
public string? ApiEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("uiEquivalent")]
|
||||
public string? UiEquivalent { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("explainSupport")]
|
||||
public bool ExplainSupport { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineSupport")]
|
||||
public bool OfflineSupport { get; init; }
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parity matrix summary response.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixResponse
|
||||
{
|
||||
[JsonPropertyName("entries")]
|
||||
public IReadOnlyList<ParityMatrixEntry> Entries { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public ParityMatrixSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("cliVersion")]
|
||||
public string? CliVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of parity matrix coverage.
|
||||
/// </summary>
|
||||
internal sealed class ParityMatrixSummary
|
||||
{
|
||||
[JsonPropertyName("totalCommands")]
|
||||
public int TotalCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("fullParity")]
|
||||
public int FullParity { get; init; }
|
||||
|
||||
[JsonPropertyName("partialParity")]
|
||||
public int PartialParity { get; init; }
|
||||
|
||||
[JsonPropertyName("noParity")]
|
||||
public int NoParity { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministicCommands")]
|
||||
public int DeterministicCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("explainEnabledCommands")]
|
||||
public int ExplainEnabledCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineCapableCommands")]
|
||||
public int OfflineCapableCommands { get; init; }
|
||||
}
|
||||
834
src/Cli/StellaOps.Cli/Services/Models/SbomerModels.cs
Normal file
834
src/Cli/StellaOps.Cli/Services/Models/SbomerModels.cs
Normal file
@@ -0,0 +1,834 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-SBOM-60-001: Sbomer command models for layer/compose operations
|
||||
|
||||
/// <summary>
|
||||
/// SBOM fragment from a container layer.
|
||||
/// </summary>
|
||||
internal sealed class SbomFragment
|
||||
{
|
||||
[JsonPropertyName("fragmentId")]
|
||||
public string FragmentId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeSha256")]
|
||||
public string? DsseEnvelopeSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeUri")]
|
||||
public string? DsseEnvelopeUri { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool? SignatureValid { get; init; }
|
||||
|
||||
[JsonPropertyName("components")]
|
||||
public IReadOnlyList<SbomFragmentComponent>? Components { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component within an SBOM fragment.
|
||||
/// </summary>
|
||||
internal sealed class SbomFragmentComponent
|
||||
{
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("identityKey")]
|
||||
public string? IdentityKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer list request for sbomer layer list.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerListRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer list response.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyList<SbomFragment> Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer show request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerShowRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("includeComponents")]
|
||||
public bool IncludeComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("includeDsse")]
|
||||
public bool IncludeDsse { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer detail response.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerDetail
|
||||
{
|
||||
[JsonPropertyName("fragment")]
|
||||
public SbomFragment Fragment { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("dsseEnvelope")]
|
||||
public DsseEnvelopeInfo? DsseEnvelope { get; init; }
|
||||
|
||||
[JsonPropertyName("canonicalJson")]
|
||||
public string? CanonicalJson { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProof")]
|
||||
public MerkleProofInfo? MerkleProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope information.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelopeInfo
|
||||
{
|
||||
[JsonPropertyName("payloadType")]
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payloadSha256")]
|
||||
public string PayloadSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatures")]
|
||||
public IReadOnlyList<DsseSignatureInfo> Signatures { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("envelopeSha256")]
|
||||
public string EnvelopeSha256 { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature information.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignatureInfo
|
||||
{
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("algorithm")]
|
||||
public string Algorithm { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("signatureSha256")]
|
||||
public string SignatureSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool? Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateSubject")]
|
||||
public string? CertificateSubject { get; init; }
|
||||
|
||||
[JsonPropertyName("certificateExpiry")]
|
||||
public DateTimeOffset? CertificateExpiry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle proof information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleProofInfo
|
||||
{
|
||||
[JsonPropertyName("leafHash")]
|
||||
public string LeafHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("proofHashes")]
|
||||
public IReadOnlyList<string> ProofHashes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("leafIndex")]
|
||||
public int LeafIndex { get; init; }
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public int TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool? Valid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer verify request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Layer verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerLayerVerifyResult
|
||||
{
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("dsseValid")]
|
||||
public bool DsseValid { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHashMatch")]
|
||||
public bool ContentHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProofValid")]
|
||||
public bool? MerkleProofValid { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureAlgorithm")]
|
||||
public string? SignatureAlgorithm { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition manifest (_composition.json).
|
||||
/// </summary>
|
||||
internal sealed class CompositionManifest
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string MerkleRoot { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("composedSha256")]
|
||||
public string ComposedSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragments")]
|
||||
public IReadOnlyList<CompositionFragmentEntry> Fragments { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("canonicalOrder")]
|
||||
public IReadOnlyList<string> CanonicalOrder { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("properties")]
|
||||
public IReadOnlyDictionary<string, string>? Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragment entry in composition manifest.
|
||||
/// </summary>
|
||||
internal sealed class CompositionFragmentEntry
|
||||
{
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("dsseEnvelopeSha256")]
|
||||
public string? DsseEnvelopeSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("componentCount")]
|
||||
public int ComponentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int Order { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose request for sbomer compose.
|
||||
/// </summary>
|
||||
internal sealed class SbomerComposeRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("imageRef")]
|
||||
public string? ImageRef { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("format")]
|
||||
public string? Format { get; init; }
|
||||
|
||||
[JsonPropertyName("verifyFragments")]
|
||||
public bool VerifyFragments { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("emitCompositionManifest")]
|
||||
public bool EmitCompositionManifest { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("emitMerkleDiagnostics")]
|
||||
public bool EmitMerkleDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerComposeResult
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("composedSha256")]
|
||||
public string? ComposedSha256 { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleRoot")]
|
||||
public string? MerkleRoot { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("totalComponents")]
|
||||
public int TotalComponents { get; init; }
|
||||
|
||||
[JsonPropertyName("outputPath")]
|
||||
public string? OutputPath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionManifestPath")]
|
||||
public string? CompositionManifestPath { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleDiagnosticsPath")]
|
||||
public string? MerkleDiagnosticsPath { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition show request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionShowRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle diagnostics for composition.
|
||||
/// </summary>
|
||||
internal sealed class MerkleDiagnostics
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string RootHash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("treeSize")]
|
||||
public int TreeSize { get; init; }
|
||||
|
||||
[JsonPropertyName("leaves")]
|
||||
public IReadOnlyList<MerkleLeafInfo> Leaves { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("intermediateNodes")]
|
||||
public IReadOnlyList<MerkleNodeInfo>? IntermediateNodes { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle leaf information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleLeafInfo
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fragmentSha256")]
|
||||
public string FragmentSha256 { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merkle intermediate node information.
|
||||
/// </summary>
|
||||
internal sealed class MerkleNodeInfo
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public int Level { get; init; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("leftChild")]
|
||||
public string? LeftChild { get; init; }
|
||||
|
||||
[JsonPropertyName("rightChild")]
|
||||
public string? RightChild { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition verify request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("recompose")]
|
||||
public bool Recompose { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composition verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerCompositionVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("merkleRootMatch")]
|
||||
public bool MerkleRootMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("composedHashMatch")]
|
||||
public bool ComposedHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("allFragmentsValid")]
|
||||
public bool AllFragmentsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHash")]
|
||||
public string? RecomposedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("expectedHash")]
|
||||
public string? ExpectedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection and explain models
|
||||
|
||||
/// <summary>
|
||||
/// Drift analysis request.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineScanId")]
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("baselinePath")]
|
||||
public string? BaselinePath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("explain")]
|
||||
public bool Explain { get; init; }
|
||||
|
||||
[JsonPropertyName("offline")]
|
||||
public bool Offline { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitPath")]
|
||||
public string? OfflineKitPath { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift analysis result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftResult
|
||||
{
|
||||
[JsonPropertyName("hasDrift")]
|
||||
public bool HasDrift { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("baselineScanId")]
|
||||
public string? BaselineScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("currentHash")]
|
||||
public string? CurrentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineHash")]
|
||||
public string? BaselineHash { get; init; }
|
||||
|
||||
[JsonPropertyName("driftSummary")]
|
||||
public DriftSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("driftDetails")]
|
||||
public IReadOnlyList<DriftDetail>? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("explanations")]
|
||||
public IReadOnlyList<DriftExplanation>? Explanations { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of drift between two SBOMs.
|
||||
/// </summary>
|
||||
internal sealed class DriftSummary
|
||||
{
|
||||
[JsonPropertyName("componentsAdded")]
|
||||
public int ComponentsAdded { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsRemoved")]
|
||||
public int ComponentsRemoved { get; init; }
|
||||
|
||||
[JsonPropertyName("componentsModified")]
|
||||
public int ComponentsModified { get; init; }
|
||||
|
||||
[JsonPropertyName("arrayOrderingDrifts")]
|
||||
public int ArrayOrderingDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("timestampDrifts")]
|
||||
public int TimestampDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("keyOrderingDrifts")]
|
||||
public int KeyOrderingDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("whitespaceDrifts")]
|
||||
public int WhitespaceDrifts { get; init; }
|
||||
|
||||
[JsonPropertyName("totalDrifts")]
|
||||
public int TotalDrifts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed drift information.
|
||||
/// </summary>
|
||||
internal sealed class DriftDetail
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("currentValue")]
|
||||
public string? CurrentValue { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineValue")]
|
||||
public string? BaselineValue { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string? LayerDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "info";
|
||||
|
||||
[JsonPropertyName("breaksDeterminism")]
|
||||
public bool BreaksDeterminism { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explanation for drift occurrence.
|
||||
/// </summary>
|
||||
internal sealed class DriftExplanation
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expectedBehavior")]
|
||||
public string? ExpectedBehavior { get; init; }
|
||||
|
||||
[JsonPropertyName("actualBehavior")]
|
||||
public string? ActualBehavior { get; init; }
|
||||
|
||||
[JsonPropertyName("rootCause")]
|
||||
public string? RootCause { get; init; }
|
||||
|
||||
[JsonPropertyName("remediation")]
|
||||
public string? Remediation { get; init; }
|
||||
|
||||
[JsonPropertyName("documentationUrl")]
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("affectedLayers")]
|
||||
public IReadOnlyList<string>? AffectedLayers { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift verify request for offline verification.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftVerifyRequest
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomPath")]
|
||||
public string? SbomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionPath")]
|
||||
public string? CompositionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPath")]
|
||||
public string? VerifiersPath { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitPath")]
|
||||
public string? OfflineKitPath { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposeLocally")]
|
||||
public bool RecomposeLocally { get; init; }
|
||||
|
||||
[JsonPropertyName("validateFragments")]
|
||||
public bool ValidateFragments { get; init; }
|
||||
|
||||
[JsonPropertyName("checkMerkleProofs")]
|
||||
public bool CheckMerkleProofs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drift verify result.
|
||||
/// </summary>
|
||||
internal sealed class SbomerDriftVerifyResult
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("deterministic")]
|
||||
public bool Deterministic { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("compositionValid")]
|
||||
public bool CompositionValid { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentsValid")]
|
||||
public bool FragmentsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("merkleProofsValid")]
|
||||
public bool MerkleProofsValid { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHashMatch")]
|
||||
public bool? RecomposedHashMatch { get; init; }
|
||||
|
||||
[JsonPropertyName("currentHash")]
|
||||
public string? CurrentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("recomposedHash")]
|
||||
public string? RecomposedHash { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentVerifications")]
|
||||
public IReadOnlyList<SbomerLayerVerifyResult>? FragmentVerifications { get; init; }
|
||||
|
||||
[JsonPropertyName("driftResult")]
|
||||
public SbomerDriftResult? DriftResult { get; init; }
|
||||
|
||||
[JsonPropertyName("offlineKitInfo")]
|
||||
public OfflineKitInfo? OfflineKitInfo { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about offline kit used for verification.
|
||||
/// </summary>
|
||||
internal sealed class OfflineKitInfo
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("fragmentCount")]
|
||||
public int FragmentCount { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiersPresent")]
|
||||
public bool VerifiersPresent { get; init; }
|
||||
|
||||
[JsonPropertyName("compositionManifestPresent")]
|
||||
public bool CompositionManifestPresent { get; init; }
|
||||
}
|
||||
282
src/Cli/StellaOps.Cli/Services/Models/SdkModels.cs
Normal file
282
src/Cli/StellaOps.Cli/Services/Models/SdkModels.cs
Normal file
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request for checking SDK updates.
|
||||
/// CLI-SDK-64-001: Supports stella sdk update command.
|
||||
/// </summary>
|
||||
internal sealed class SdkUpdateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant context for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("tenant")]
|
||||
public string? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Language filter (typescript, go, csharp, python, java).
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to only check for updates without downloading.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool CheckOnly { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include changelog information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeChangelog")]
|
||||
public bool IncludeChangelog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include deprecation notices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("includeDeprecations")]
|
||||
public bool IncludeDeprecations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for SDK update check.
|
||||
/// </summary>
|
||||
internal sealed class SdkUpdateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Available SDK updates.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updates")]
|
||||
public IReadOnlyList<SdkVersionInfo> Updates { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation notices.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecations")]
|
||||
public IReadOnlyList<SdkDeprecation> Deprecations { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when updates were last checked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("checkedAt")]
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK version information.
|
||||
/// </summary>
|
||||
internal sealed class SdkVersionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK language (typescript, go, csharp, python, java).
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the SDK.
|
||||
/// </summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name (e.g., @stellaops/sdk, stellaops-sdk).
|
||||
/// </summary>
|
||||
[JsonPropertyName("packageName")]
|
||||
public required string PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current installed version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("installedVersion")]
|
||||
public string? InstalledVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest available version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("latestVersion")]
|
||||
public required string LatestVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an update is available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("updateAvailable")]
|
||||
public bool UpdateAvailable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum supported API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("minApiVersion")]
|
||||
public string? MinApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum supported API version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxApiVersion")]
|
||||
public string? MaxApiVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date of the latest version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Changelog for recent versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("changelog")]
|
||||
public IReadOnlyList<SdkChangelogEntry>? Changelog { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Download URL for the package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("downloadUrl")]
|
||||
public string? DownloadUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package registry URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("registryUrl")]
|
||||
public string? RegistryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Documentation URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("docsUrl")]
|
||||
public string? DocsUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK changelog entry.
|
||||
/// </summary>
|
||||
internal sealed class SdkChangelogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Version number.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("releaseDate")]
|
||||
public DateTimeOffset? ReleaseDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change type (feature, fix, breaking, deprecation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a breaking change.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isBreaking")]
|
||||
public bool IsBreaking { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to more details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("link")]
|
||||
public string? Link { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK deprecation notice.
|
||||
/// </summary>
|
||||
internal sealed class SdkDeprecation
|
||||
{
|
||||
/// <summary>
|
||||
/// SDK language affected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecated feature or API.
|
||||
/// </summary>
|
||||
[JsonPropertyName("feature")]
|
||||
public required string Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version when deprecation was introduced.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deprecatedInVersion")]
|
||||
public string? DeprecatedInVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version when feature will be removed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("removedInVersion")]
|
||||
public string? RemovedInVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Replacement or migration path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replacement")]
|
||||
public string? Replacement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to migration guide.
|
||||
/// </summary>
|
||||
[JsonPropertyName("migrationGuide")]
|
||||
public string? MigrationGuide { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of the deprecation (info, warning, critical).
|
||||
/// </summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; init; } = "warning";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing installed SDKs.
|
||||
/// </summary>
|
||||
internal sealed class SdkListResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation was successful.
|
||||
/// </summary>
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Installed SDK versions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sdks")]
|
||||
public IReadOnlyList<SdkVersionInfo> Sdks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the operation failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -14,6 +14,15 @@ internal sealed class PolicySimulationRequestDocument
|
||||
public Dictionary<string, JsonElement>? Env { get; set; }
|
||||
|
||||
public bool? Explain { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced simulation options
|
||||
public string? Mode { get; set; }
|
||||
|
||||
public IReadOnlyList<string>? SbomSelectors { get; set; }
|
||||
|
||||
public bool? IncludeHeatmap { get; set; }
|
||||
|
||||
public bool? IncludeManifest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationResponseDocument
|
||||
@@ -21,6 +30,13 @@ internal sealed class PolicySimulationResponseDocument
|
||||
public PolicySimulationDiffDocument? Diff { get; set; }
|
||||
|
||||
public string? ExplainUri { get; set; }
|
||||
|
||||
// CLI-POLICY-27-003: Enhanced response fields
|
||||
public PolicySimulationHeatmapDocument? Heatmap { get; set; }
|
||||
|
||||
public string? ManifestDownloadUri { get; set; }
|
||||
|
||||
public string? ManifestDigest { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationDiffDocument
|
||||
@@ -55,3 +71,28 @@ internal sealed class PolicySimulationRuleDeltaDocument
|
||||
|
||||
public int? Down { get; set; }
|
||||
}
|
||||
|
||||
// CLI-POLICY-27-003: Heatmap response documents
|
||||
internal sealed class PolicySimulationHeatmapDocument
|
||||
{
|
||||
public int? Critical { get; set; }
|
||||
|
||||
public int? High { get; set; }
|
||||
|
||||
public int? Medium { get; set; }
|
||||
|
||||
public int? Low { get; set; }
|
||||
|
||||
public int? Info { get; set; }
|
||||
|
||||
public List<PolicySimulationHeatmapBucketDocument>? Buckets { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class PolicySimulationHeatmapBucketDocument
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
|
||||
public int? Count { get; set; }
|
||||
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,184 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 7807 Problem Details response.
|
||||
/// </summary>
|
||||
internal sealed class ProblemDocument
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public int? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("instance")]
|
||||
public string? Instance { get; set; }
|
||||
|
||||
[JsonPropertyName("extensions")]
|
||||
public Dictionary<string, object?>? Extensions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standardized API error envelope with error.code and trace_id.
|
||||
/// CLI-SDK-62-002: Supports surfacing structured error information.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public ApiErrorDetail? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("trace_id")]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("request_id")]
|
||||
public string? RequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error detail within the standardized envelope.
|
||||
/// </summary>
|
||||
internal sealed class ApiErrorDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable error code (e.g., "ERR_AUTH_INVALID_SCOPE").
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public string? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed description of the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error (field name, resource identifier).
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors for nested error details.
|
||||
/// </summary>
|
||||
[JsonPropertyName("inner_errors")]
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, object?>? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
[JsonPropertyName("help_url")]
|
||||
public string? HelpUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds (for rate limiting).
|
||||
/// </summary>
|
||||
[JsonPropertyName("retry_after")]
|
||||
public int? RetryAfter { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed API error result combining multiple error formats.
|
||||
/// </summary>
|
||||
internal sealed class ParsedApiError
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code (from envelope, problem, or HTTP status).
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error description.
|
||||
/// </summary>
|
||||
public string? Detail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for distributed tracing.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request ID.
|
||||
/// </summary>
|
||||
public string? RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP status code.
|
||||
/// </summary>
|
||||
public int HttpStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error.
|
||||
/// </summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Help URL for more information.
|
||||
/// </summary>
|
||||
public string? HelpUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds.
|
||||
/// </summary>
|
||||
public int? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner errors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ApiErrorDetail>? InnerErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original problem document if parsed.
|
||||
/// </summary>
|
||||
public ProblemDocument? ProblemDocument { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original error envelope if parsed.
|
||||
/// </summary>
|
||||
public ApiErrorEnvelope? ErrorEnvelope { get; init; }
|
||||
}
|
||||
|
||||
292
src/Cli/StellaOps.Cli/Services/Models/VexObservationModels.cs
Normal file
292
src/Cli/StellaOps.Cli/Services/Models/VexObservationModels.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models;
|
||||
|
||||
// CLI-LNM-22-002: VEX observation models for CLI commands
|
||||
|
||||
/// <summary>
|
||||
/// Query options for VEX observations.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationQuery
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("providerIds")]
|
||||
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from VEX observation query.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationResponse
|
||||
{
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
|
||||
|
||||
[JsonPropertyName("aggregate")]
|
||||
public VexObservationAggregate? Aggregate { get; init; }
|
||||
|
||||
[JsonPropertyName("nextCursor")]
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
[JsonPropertyName("hasMore")]
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX observation document.
|
||||
/// </summary>
|
||||
internal sealed class VexObservation
|
||||
{
|
||||
[JsonPropertyName("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("providerId")]
|
||||
public string ProviderId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("product")]
|
||||
public VexObservationProduct? Product { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("document")]
|
||||
public VexObservationDocument? Document { get; init; }
|
||||
|
||||
[JsonPropertyName("firstSeen")]
|
||||
public DateTimeOffset FirstSeen { get; init; }
|
||||
|
||||
[JsonPropertyName("lastSeen")]
|
||||
public DateTimeOffset LastSeen { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public VexObservationConfidence? Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product information in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationProduct
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("cpe")]
|
||||
public string? Cpe { get; init; }
|
||||
|
||||
[JsonPropertyName("componentIdentifiers")]
|
||||
public IReadOnlyList<string> ComponentIdentifiers { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Document reference in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationDocument
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string Digest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sourceUri")]
|
||||
public string SourceUri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("revision")]
|
||||
public string? Revision { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public VexObservationSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature metadata for VEX document.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationSignature
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; init; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("transparencyLogReference")]
|
||||
public string? TransparencyLogReference { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level in VEX observation.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationConfidence
|
||||
{
|
||||
[JsonPropertyName("level")]
|
||||
public string Level { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("score")]
|
||||
public double? Score { get; init; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate data from VEX observation query.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationAggregate
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public IReadOnlyList<string> VulnerabilityIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("cpes")]
|
||||
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("providerIds")]
|
||||
public IReadOnlyList<string> ProviderIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statusCounts")]
|
||||
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX linkset query options.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetQuery
|
||||
{
|
||||
[JsonPropertyName("tenant")]
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("productKeys")]
|
||||
public IReadOnlyList<string> ProductKeys { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statuses")]
|
||||
public IReadOnlyList<string> Statuses { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX linkset response showing linked observations.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetResponse
|
||||
{
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public string VulnerabilityId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<VexObservation> Observations { get; init; } = Array.Empty<VexObservation>();
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public VexLinksetSummary? Summary { get; init; }
|
||||
|
||||
[JsonPropertyName("conflicts")]
|
||||
public IReadOnlyList<VexLinksetConflict> Conflicts { get; init; } = Array.Empty<VexLinksetConflict>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of VEX linkset.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetSummary
|
||||
{
|
||||
[JsonPropertyName("totalObservations")]
|
||||
public int TotalObservations { get; init; }
|
||||
|
||||
[JsonPropertyName("providers")]
|
||||
public IReadOnlyList<string> Providers { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("products")]
|
||||
public IReadOnlyList<string> Products { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("statusCounts")]
|
||||
public IReadOnlyDictionary<string, int> StatusCounts { get; init; } = new Dictionary<string, int>();
|
||||
|
||||
[JsonPropertyName("hasConflicts")]
|
||||
public bool HasConflicts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conflict between VEX observations.
|
||||
/// </summary>
|
||||
internal sealed class VexLinksetConflict
|
||||
{
|
||||
[JsonPropertyName("productKey")]
|
||||
public string ProductKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("conflictingStatuses")]
|
||||
public IReadOnlyList<string> ConflictingStatuses { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("observations")]
|
||||
public IReadOnlyList<string> ObservationIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
}
|
||||
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
@@ -0,0 +1,654 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for Notify API operations.
|
||||
/// Per CLI-PARITY-41-002.
|
||||
/// </summary>
|
||||
internal sealed class NotifyClient : INotifyClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<NotifyClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public NotifyClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<NotifyClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelListResponse> ListChannelsAsync(
|
||||
NotifyChannelListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildChannelListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify channels (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyChannelListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify channels");
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify channels");
|
||||
return new NotifyChannelListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelDetail?> GetChannelAsync(
|
||||
string channelId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/notify/channels/{Uri.EscapeDataString(channelId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get notify channel (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelDetail>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting notify channel");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting notify channel");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyChannelTestResult> TestChannelAsync(
|
||||
NotifyChannelTestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{Uri.EscapeDataString(request.ChannelId)}/test")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to test notify channel (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyChannelTestResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = "Empty response" };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while testing notify channel");
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = $"Connection error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while testing notify channel");
|
||||
return new NotifyChannelTestResult
|
||||
{
|
||||
Success = false,
|
||||
ChannelId = request.ChannelId,
|
||||
ErrorMessage = "Request timed out"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyRuleListResponse> ListRulesAsync(
|
||||
NotifyRuleListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.Enabled.HasValue)
|
||||
{
|
||||
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EventType))
|
||||
{
|
||||
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ChannelId))
|
||||
{
|
||||
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/notify/rules{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify rules (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyRuleListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyRuleListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify rules");
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify rules");
|
||||
return new NotifyRuleListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
|
||||
NotifyDeliveryListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ChannelId))
|
||||
{
|
||||
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
{
|
||||
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.EventType))
|
||||
{
|
||||
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
|
||||
}
|
||||
if (request.Since.HasValue)
|
||||
{
|
||||
queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.Until.HasValue)
|
||||
{
|
||||
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/notify/deliveries{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list notify deliveries (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyDeliveryListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyDeliveryListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing notify deliveries");
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing notify deliveries");
|
||||
return new NotifyDeliveryListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyDeliveryDetail?> GetDeliveryAsync(
|
||||
string deliveryId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = $"/api/v1/notify/deliveries/{Uri.EscapeDataString(deliveryId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get notify delivery (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<NotifyDeliveryDetail>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting notify delivery");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting notify delivery");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifyRetryResult> RetryDeliveryAsync(
|
||||
NotifyRetryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/deliveries/{Uri.EscapeDataString(request.DeliveryId)}/retry")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
// Add idempotency key header if present
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to retry notify delivery (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifyRetryResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while retrying notify delivery");
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while retrying notify delivery");
|
||||
return new NotifyRetryResult
|
||||
{
|
||||
Success = false,
|
||||
DeliveryId = request.DeliveryId,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotifySendResult> SendAsync(
|
||||
NotifySendRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/send")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
// Add idempotency key header if present
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
|
||||
}
|
||||
|
||||
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to send notification (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<NotifySendResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new NotifySendResult { Success = false, Errors = ["Empty response"] };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while sending notification");
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while sending notification");
|
||||
return new NotifySendResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildChannelListUri(NotifyChannelListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
queryParams.Add($"type={Uri.EscapeDataString(request.Type)}");
|
||||
}
|
||||
if (request.Enabled.HasValue)
|
||||
{
|
||||
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/notify/channels{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
557
src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs
Normal file
557
src/Cli/StellaOps.Cli/Services/ObservabilityClient.cs
Normal file
@@ -0,0 +1,557 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for observability API operations.
|
||||
/// Per CLI-OBS-51-001.
|
||||
/// </summary>
|
||||
internal sealed class ObservabilityClient : IObservabilityClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<ObservabilityClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public ObservabilityClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<ObservabilityClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ObsTopResult> GetHealthSummaryAsync(
|
||||
ObsTopRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildHealthSummaryUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get health summary (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summary = await JsonSerializer
|
||||
.DeserializeAsync<PlatformHealthSummary>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = true,
|
||||
Summary = summary ?? new PlatformHealthSummary()
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching health summary");
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching health summary");
|
||||
return new ObsTopResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildHealthSummaryUri(ObsTopRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (request.Services.Count > 0)
|
||||
{
|
||||
foreach (var service in request.Services)
|
||||
{
|
||||
queryParams.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"includeQueues={request.IncludeQueues.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"maxAlerts={request.MaxAlerts}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/observability/health{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = ["obs:read"] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Trace retrieval
|
||||
public async Task<ObsTraceResult> GetTraceAsync(
|
||||
ObsTraceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildTraceUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Trace not found: {request.TraceId}"]
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get trace (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var trace = await JsonSerializer
|
||||
.DeserializeAsync<DistributedTrace>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = true,
|
||||
Trace = trace
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching trace");
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching trace");
|
||||
return new ObsTraceResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildTraceUri(ObsTraceRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"includeEvidence={request.IncludeEvidence.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/observability/traces/{Uri.EscapeDataString(request.TraceId)}{query}";
|
||||
}
|
||||
|
||||
// CLI-OBS-52-001: Logs retrieval
|
||||
public async Task<ObsLogsResult> GetLogsAsync(
|
||||
ObsLogsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var requestUri = BuildLogsUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get logs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ObsLogsResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ObsLogsResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching logs");
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching logs");
|
||||
return new ObsLogsResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildLogsUri(ObsLogsRequest request)
|
||||
{
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"from={Uri.EscapeDataString(request.From.ToString("o"))}",
|
||||
$"to={Uri.EscapeDataString(request.To.ToString("o"))}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
|
||||
foreach (var service in request.Services)
|
||||
{
|
||||
queryParams.Add($"service={Uri.EscapeDataString(service)}");
|
||||
}
|
||||
|
||||
foreach (var level in request.Levels)
|
||||
{
|
||||
queryParams.Add($"level={Uri.EscapeDataString(level)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
queryParams.Add($"q={Uri.EscapeDataString(request.Query)}");
|
||||
}
|
||||
|
||||
queryParams.Add($"pageSize={request.PageSize}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
{
|
||||
queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}");
|
||||
}
|
||||
|
||||
var query = "?" + string.Join("&", queryParams);
|
||||
return $"/api/v1/observability/logs{query}";
|
||||
}
|
||||
|
||||
// CLI-OBS-55-001: Incident mode operations
|
||||
public async Task<IncidentModeResult> GetIncidentModeStatusAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var query = !string.IsNullOrWhiteSpace(tenant)
|
||||
? $"?tenant={Uri.EscapeDataString(tenant)}"
|
||||
: string.Empty;
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/observability/incident-mode{query}");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get incident mode status (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var state = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeState>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = true,
|
||||
State = state
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while fetching incident mode status");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while fetching incident mode status");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IncidentModeResult> EnableIncidentModeAsync(
|
||||
IncidentModeEnableRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/enable");
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to enable incident mode (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new IncidentModeResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while enabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while enabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IncidentModeResult> DisableIncidentModeAsync(
|
||||
IncidentModeDisableRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/disable");
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to disable incident mode (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
};
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<IncidentModeResult>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new IncidentModeResult { Success = true };
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while disabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while disabling incident mode");
|
||||
return new IncidentModeResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
463
src/Cli/StellaOps.Cli/Services/OrchestratorClient.cs
Normal file
463
src/Cli/StellaOps.Cli/Services/OrchestratorClient.cs
Normal file
@@ -0,0 +1,463 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.Client.Scopes;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for orchestrator API operations.
|
||||
/// Per CLI-ORCH-32-001.
|
||||
/// </summary>
|
||||
internal sealed class OrchestratorClient : IOrchestratorClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IStellaOpsTokenClient _tokenClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly ILogger<OrchestratorClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public OrchestratorClient(
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient tokenClient,
|
||||
IOptions<StellaOpsCliOptions> options,
|
||||
ILogger<OrchestratorClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SourceListResponse> ListSourcesAsync(
|
||||
SourceListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildSourcesListUrl(request);
|
||||
|
||||
_logger.LogDebug("Listing orchestrator sources: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to list sources: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to list sources: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceListResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceListResponse();
|
||||
}
|
||||
|
||||
public async Task<OrchestratorSource?> GetSourceAsync(
|
||||
string sourceId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(sourceId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
url += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Getting orchestrator source: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get source: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OrchestratorSource>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> PauseSourceAsync(
|
||||
SourcePauseRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:pause";
|
||||
|
||||
_logger.LogDebug("Pausing orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to pause source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to pause source: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> ResumeSourceAsync(
|
||||
SourceResumeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:resume";
|
||||
|
||||
_logger.LogDebug("Resuming orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to resume source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to resume source: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<SourceTestResult> TestSourceAsync(
|
||||
SourceTestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/sources/{Uri.EscapeDataString(request.SourceId)}:test";
|
||||
|
||||
_logger.LogDebug("Testing orchestrator source: {SourceId}", request.SourceId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to test source: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceTestResult
|
||||
{
|
||||
Success = false,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = false,
|
||||
ErrorMessage = $"Failed to test source: {response.StatusCode} - {errorContent}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceTestResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceTestResult
|
||||
{
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Reachable = true,
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Backfill operations
|
||||
|
||||
public async Task<BackfillResult> StartBackfillAsync(
|
||||
BackfillRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills";
|
||||
|
||||
_logger.LogDebug("Starting backfill for source: {SourceId} from {From} to {To}",
|
||||
request.SourceId, request.From, request.To);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to start backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new BackfillResult
|
||||
{
|
||||
Success = false,
|
||||
SourceId = request.SourceId,
|
||||
Status = BackfillStatuses.Failed,
|
||||
From = request.From,
|
||||
To = request.To,
|
||||
DryRun = request.DryRun,
|
||||
Errors = new[] { $"Failed to start backfill: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new BackfillResult
|
||||
{
|
||||
Success = true,
|
||||
SourceId = request.SourceId,
|
||||
Status = request.DryRun ? BackfillStatuses.DryRun : BackfillStatuses.Pending,
|
||||
From = request.From,
|
||||
To = request.To,
|
||||
DryRun = request.DryRun
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BackfillResult?> GetBackfillAsync(
|
||||
string backfillId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(backfillId)}";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
url += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Getting backfill status: {BackfillId}", backfillId);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get backfill: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<BackfillResult>(JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<BackfillListResponse> ListBackfillsAsync(
|
||||
BackfillListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildBackfillsListUrl(request);
|
||||
|
||||
_logger.LogDebug("Listing backfills: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to list backfills: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to list backfills: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BackfillListResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new BackfillListResponse();
|
||||
}
|
||||
|
||||
public async Task<SourceOperationResult> CancelBackfillAsync(
|
||||
BackfillCancelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/backfills/{Uri.EscapeDataString(request.BackfillId)}:cancel";
|
||||
|
||||
_logger.LogDebug("Cancelling backfill: {BackfillId}", request.BackfillId);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to cancel backfill: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new SourceOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to cancel backfill: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SourceOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new SourceOperationResult { Success = true };
|
||||
}
|
||||
|
||||
// CLI-ORCH-34-001: Quota management
|
||||
|
||||
public async Task<QuotaGetResponse> GetQuotasAsync(
|
||||
QuotaGetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = BuildQuotasGetUrl(request);
|
||||
|
||||
_logger.LogDebug("Getting quotas: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to get quotas: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
throw new HttpRequestException($"Failed to get quotas: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaGetResponse>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaGetResponse();
|
||||
}
|
||||
|
||||
public async Task<QuotaOperationResult> SetQuotaAsync(
|
||||
QuotaSetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/quotas";
|
||||
|
||||
_logger.LogDebug("Setting quota for tenant: {Tenant}, resource: {ResourceType}",
|
||||
request.Tenant, request.ResourceType);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to set quota: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new QuotaOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to set quota: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaOperationResult { Success = true };
|
||||
}
|
||||
|
||||
public async Task<QuotaOperationResult> ResetQuotaAsync(
|
||||
QuotaResetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ConfigureAuthAsync(cancellationToken);
|
||||
var url = $"{GetBaseUrl()}/quotas:reset";
|
||||
|
||||
_logger.LogDebug("Resetting quota for tenant: {Tenant}, resource: {ResourceType}",
|
||||
request.Tenant, request.ResourceType);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Failed to reset quota: {StatusCode} {Content}", response.StatusCode, errorContent);
|
||||
return new QuotaOperationResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = new[] { $"Failed to reset quota: {response.StatusCode} - {errorContent}" }
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<QuotaOperationResult>(JsonOptions, cancellationToken);
|
||||
return result ?? new QuotaOperationResult { Success = true };
|
||||
}
|
||||
|
||||
private async Task ConfigureAuthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _tokenClient.GetCachedAccessTokenAsync(
|
||||
new[] { StellaOpsScope.OrchRead },
|
||||
cancellationToken);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.AccessToken);
|
||||
}
|
||||
|
||||
private string GetBaseUrl()
|
||||
{
|
||||
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "https://api.stellaops.local";
|
||||
return $"{baseUrl}/api/v1/orchestrator";
|
||||
}
|
||||
|
||||
private string BuildSourcesListUrl(SourceListRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/sources");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.Type))
|
||||
query["type"] = request.Type;
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
query["status"] = request.Status;
|
||||
if (request.Enabled.HasValue)
|
||||
query["enabled"] = request.Enabled.Value.ToString().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(request.Host))
|
||||
query["host"] = request.Host;
|
||||
if (!string.IsNullOrWhiteSpace(request.Tag))
|
||||
query["tag"] = request.Tag;
|
||||
if (request.PageSize != 50)
|
||||
query["page_size"] = request.PageSize.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
query["page_token"] = request.PageToken;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string BuildBackfillsListUrl(BackfillListRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/backfills");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SourceId))
|
||||
query["source_id"] = request.SourceId;
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.Status))
|
||||
query["status"] = request.Status;
|
||||
if (request.PageSize != 20)
|
||||
query["page_size"] = request.PageSize.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(request.PageToken))
|
||||
query["page_token"] = request.PageToken;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string BuildQuotasGetUrl(QuotaGetRequest request)
|
||||
{
|
||||
var builder = new UriBuilder($"{GetBaseUrl()}/quotas");
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
query["tenant"] = request.Tenant;
|
||||
if (!string.IsNullOrWhiteSpace(request.SourceId))
|
||||
query["source_id"] = request.SourceId;
|
||||
if (!string.IsNullOrWhiteSpace(request.ResourceType))
|
||||
query["resource_type"] = request.ResourceType;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
1017
src/Cli/StellaOps.Cli/Services/PackClient.cs
Normal file
1017
src/Cli/StellaOps.Cli/Services/PackClient.cs
Normal file
File diff suppressed because it is too large
Load Diff
1118
src/Cli/StellaOps.Cli/Services/PromotionAssembler.cs
Normal file
1118
src/Cli/StellaOps.Cli/Services/PromotionAssembler.cs
Normal file
File diff suppressed because it is too large
Load Diff
483
src/Cli/StellaOps.Cli/Services/SbomClient.cs
Normal file
483
src/Cli/StellaOps.Cli/Services/SbomClient.cs
Normal file
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Client for SBOM API operations.
|
||||
/// Per CLI-PARITY-41-001.
|
||||
/// </summary>
|
||||
internal sealed class SbomClient : ISbomClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly StellaOpsCliOptions options;
|
||||
private readonly ILogger<SbomClient> logger;
|
||||
private readonly IStellaOpsTokenClient? tokenClient;
|
||||
private readonly object tokenSync = new();
|
||||
|
||||
private string? cachedAccessToken;
|
||||
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public SbomClient(
|
||||
HttpClient httpClient,
|
||||
StellaOpsCliOptions options,
|
||||
ILogger<SbomClient> logger,
|
||||
IStellaOpsTokenClient? tokenClient = null)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.tokenClient = tokenClient;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomListResponse> ListAsync(
|
||||
SbomListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = BuildListUri(request);
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to list SBOMs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new SbomListResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<SbomListResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new SbomListResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while listing SBOMs");
|
||||
return new SbomListResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while listing SBOMs");
|
||||
return new SbomListResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomDetailResponse?> GetAsync(
|
||||
string sbomId,
|
||||
string? tenant,
|
||||
bool includeComponents,
|
||||
bool includeVulnerabilities,
|
||||
bool includeLicenses,
|
||||
bool explain,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomId);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(tenant)}");
|
||||
}
|
||||
if (includeComponents)
|
||||
{
|
||||
queryParams.Add("includeComponents=true");
|
||||
}
|
||||
if (includeVulnerabilities)
|
||||
{
|
||||
queryParams.Add("includeVulnerabilities=true");
|
||||
}
|
||||
if (includeLicenses)
|
||||
{
|
||||
queryParams.Add("includeLicenses=true");
|
||||
}
|
||||
if (explain)
|
||||
{
|
||||
queryParams.Add("explain=true");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(sbomId)}{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomDetailResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting SBOM");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting SBOM");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SbomCompareResponse?> CompareAsync(
|
||||
SbomCompareRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"base={Uri.EscapeDataString(request.BaseSbomId)}",
|
||||
$"target={Uri.EscapeDataString(request.TargetSbomId)}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (request.IncludeUnchanged)
|
||||
{
|
||||
queryParams.Add("includeUnchanged=true");
|
||||
}
|
||||
|
||||
var query = string.Join("&", queryParams);
|
||||
var uri = $"/api/v1/sboms/compare?{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to compare SBOMs (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer
|
||||
.DeserializeAsync<SbomCompareResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while comparing SBOMs");
|
||||
return null;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while comparing SBOMs");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(Stream Content, SbomExportResult? Result)> ExportAsync(
|
||||
SbomExportRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var queryParams = new List<string>
|
||||
{
|
||||
$"format={Uri.EscapeDataString(request.Format)}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.FormatVersion))
|
||||
{
|
||||
queryParams.Add($"formatVersion={Uri.EscapeDataString(request.FormatVersion)}");
|
||||
}
|
||||
queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}");
|
||||
queryParams.Add($"includeVex={request.IncludeVex.ToString().ToLowerInvariant()}");
|
||||
|
||||
var query = string.Join("&", queryParams);
|
||||
var uri = $"/api/v1/sboms/{Uri.EscapeDataString(request.SbomId)}/export?{query}";
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "sbom.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to export SBOM (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
|
||||
});
|
||||
}
|
||||
|
||||
// Parse export metadata from headers if present
|
||||
SbomExportResult? result = null;
|
||||
if (response.Headers.TryGetValues("X-Export-Metadata", out var metadataValues))
|
||||
{
|
||||
var metadataJson = string.Join("", metadataValues);
|
||||
if (!string.IsNullOrWhiteSpace(metadataJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
result = JsonSerializer.Deserialize<SbomExportResult>(metadataJson, SerializerOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore parse errors for optional header
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result ??= new SbomExportResult
|
||||
{
|
||||
Success = true,
|
||||
Format = request.Format,
|
||||
Signed = request.Signed
|
||||
};
|
||||
|
||||
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (contentStream, result);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while exporting SBOM");
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = [$"Connection error: {ex.Message}"]
|
||||
});
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while exporting SBOM");
|
||||
return (Stream.Null, new SbomExportResult
|
||||
{
|
||||
Success = false,
|
||||
Errors = ["Request timed out"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ParityMatrixResponse> GetParityMatrixAsync(
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var uri = "/api/v1/cli/parity-matrix";
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
}
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
await AuthorizeRequestAsync(httpRequest, "cli.read", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Failed to get parity matrix (status {StatusCode}). Response: {Payload}",
|
||||
(int)response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
|
||||
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await JsonSerializer
|
||||
.DeserializeAsync<ParityMatrixResponse>(stream, SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ParityMatrixResponse();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogError(ex, "HTTP error while getting parity matrix");
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Request timed out while getting parity matrix");
|
||||
return new ParityMatrixResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildListUri(SbomListRequest request)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
{
|
||||
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ImageRef))
|
||||
{
|
||||
queryParams.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Digest))
|
||||
{
|
||||
queryParams.Add($"digest={Uri.EscapeDataString(request.Digest)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Format))
|
||||
{
|
||||
queryParams.Add($"format={Uri.EscapeDataString(request.Format)}");
|
||||
}
|
||||
if (request.CreatedAfter.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdAfter={Uri.EscapeDataString(request.CreatedAfter.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.CreatedBefore.HasValue)
|
||||
{
|
||||
queryParams.Add($"createdBefore={Uri.EscapeDataString(request.CreatedBefore.Value.ToString("O"))}");
|
||||
}
|
||||
if (request.HasVulnerabilities.HasValue)
|
||||
{
|
||||
queryParams.Add($"hasVulnerabilities={request.HasVulnerabilities.Value.ToString().ToLowerInvariant()}");
|
||||
}
|
||||
if (request.Limit.HasValue)
|
||||
{
|
||||
queryParams.Add($"limit={request.Limit.Value}");
|
||||
}
|
||||
if (request.Offset.HasValue)
|
||||
{
|
||||
queryParams.Add($"offset={request.Offset.Value}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
{
|
||||
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
}
|
||||
|
||||
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
|
||||
return $"/api/v1/sboms{query}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tokenClient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (tokenSync)
|
||||
{
|
||||
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
|
||||
{
|
||||
return cachedAccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
254
src/Cli/StellaOps.Cli/Services/SbomerClient.cs
Normal file
254
src/Cli/StellaOps.Cli/Services/SbomerClient.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for Sbomer API operations.
|
||||
/// Per CLI-SBOM-60-001.
|
||||
/// </summary>
|
||||
internal sealed class SbomerClient : ISbomerClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IStellaOpsTokenClient? _tokenClient;
|
||||
private readonly ILogger<SbomerClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public SbomerClient(
|
||||
HttpClient httpClient,
|
||||
IStellaOpsTokenClient? tokenClient,
|
||||
ILogger<SbomerClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_tokenClient = tokenClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerListResponse> ListLayersAsync(
|
||||
SbomerLayerListRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildQueryString(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers{query}", cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerListResponse>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerLayerListResponse();
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerDetail?> GetLayerAsync(
|
||||
SbomerLayerShowRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildLayerShowQuery(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerDetail>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<SbomerLayerVerifyResult> VerifyLayerAsync(
|
||||
SbomerLayerVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
$"/api/v1/sbomer/layers/{Uri.EscapeDataString(request.LayerDigest)}/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerLayerVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerLayerVerifyResult { LayerDigest = request.LayerDigest };
|
||||
}
|
||||
|
||||
public async Task<CompositionManifest?> GetCompositionManifestAsync(
|
||||
SbomerCompositionShowRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = BuildCompositionShowQuery(request);
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<CompositionManifest>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<SbomerComposeResult> ComposeAsync(
|
||||
SbomerComposeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/compose",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerComposeResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerComposeResult();
|
||||
}
|
||||
|
||||
public async Task<SbomerCompositionVerifyResult> VerifyCompositionAsync(
|
||||
SbomerCompositionVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/composition/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerCompositionVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerCompositionVerifyResult();
|
||||
}
|
||||
|
||||
public async Task<MerkleDiagnostics?> GetMerkleDiagnosticsAsync(
|
||||
string scanId,
|
||||
string? tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var query = string.IsNullOrWhiteSpace(tenant) ? "" : $"?tenant={Uri.EscapeDataString(tenant)}";
|
||||
var response = await _httpClient.GetAsync($"/api/v1/sbomer/composition/{Uri.EscapeDataString(scanId)}/merkle{query}", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<MerkleDiagnostics>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// CLI-SBOM-60-002: Drift detection methods
|
||||
|
||||
public async Task<SbomerDriftResult> AnalyzeDriftAsync(
|
||||
SbomerDriftRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/drift/analyze",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerDriftResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerDriftResult();
|
||||
}
|
||||
|
||||
public async Task<SbomerDriftVerifyResult> VerifyDriftAsync(
|
||||
SbomerDriftVerifyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureAuthenticatedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"/api/v1/sbomer/drift/verify",
|
||||
request,
|
||||
JsonOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<SbomerDriftVerifyResult>(JsonOptions, cancellationToken).ConfigureAwait(false)
|
||||
?? new SbomerDriftVerifyResult();
|
||||
}
|
||||
|
||||
private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tokenClient == null)
|
||||
return;
|
||||
|
||||
var token = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildQueryString(SbomerLayerListRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ImageRef))
|
||||
parts.Add($"imageRef={Uri.EscapeDataString(request.ImageRef)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Digest))
|
||||
parts.Add($"digest={Uri.EscapeDataString(request.Digest)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (request.Limit.HasValue)
|
||||
parts.Add($"limit={request.Limit.Value}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Cursor))
|
||||
parts.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
|
||||
private static string BuildLayerShowQuery(SbomerLayerShowRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (request.IncludeComponents)
|
||||
parts.Add("includeComponents=true");
|
||||
if (request.IncludeDsse)
|
||||
parts.Add("includeDsse=true");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
|
||||
private static string BuildCompositionShowQuery(SbomerCompositionShowRequest request)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Tenant))
|
||||
parts.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.ScanId))
|
||||
parts.Add($"scanId={Uri.EscapeDataString(request.ScanId)}");
|
||||
if (!string.IsNullOrWhiteSpace(request.CompositionPath))
|
||||
parts.Add($"compositionPath={Uri.EscapeDataString(request.CompositionPath)}");
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : "";
|
||||
}
|
||||
}
|
||||
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
164
src/Cli/StellaOps.Cli/Services/Transport/HttpTransport.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP transport implementation for online mode.
|
||||
/// CLI-SDK-62-001: Provides HTTP transport for online API operations.
|
||||
/// </summary>
|
||||
public sealed class HttpTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<HttpTransport> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public HttpTransport(HttpClient httpClient, TransportOptions options, ILogger<HttpTransport> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && _httpClient.BaseAddress is null)
|
||||
{
|
||||
if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
_httpClient.BaseAddress = baseUri;
|
||||
}
|
||||
}
|
||||
|
||||
_httpClient.Timeout = _options.Timeout;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "http";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var attempt = 0;
|
||||
var maxAttempts = Math.Max(1, _options.MaxRetries);
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Sending {Method} request to {Uri} (attempt {Attempt}/{MaxAttempts})",
|
||||
request.Method, request.RequestUri, attempt, maxAttempts);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Received response {StatusCode} from {Uri}",
|
||||
(int)response.StatusCode, request.RequestUri);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (HttpRequestException ex) when (attempt < maxAttempts && IsRetryableException(ex))
|
||||
{
|
||||
var delay = GetRetryDelay(attempt);
|
||||
_logger.LogWarning(ex, "Request failed (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s...",
|
||||
attempt, maxAttempts, delay.TotalSeconds);
|
||||
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clone the request for retry
|
||||
request = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// For HTTP transport, we return a memory stream that will be uploaded
|
||||
// The caller is responsible for writing to the stream and then calling upload
|
||||
return await Task.FromResult<Stream>(new MemoryStream()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||
var response = await SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsRetryableException(HttpRequestException ex)
|
||||
{
|
||||
// Retry on connection errors, timeouts, and server errors
|
||||
return ex.InnerException is IOException
|
||||
|| ex.InnerException is OperationCanceledException
|
||||
|| (ex.StatusCode.HasValue && (int)ex.StatusCode.Value >= 500);
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
// Exponential backoff with jitter
|
||||
var baseDelay = Math.Pow(2, attempt);
|
||||
var jitter = Random.Shared.NextDouble() * 0.5;
|
||||
return TimeSpan.FromSeconds(baseDelay + jitter);
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
|
||||
|
||||
// Copy headers
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
// Copy content if present
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var content = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
clone.Content = new ByteArrayContent(content);
|
||||
|
||||
foreach (var header in request.Content.Headers)
|
||||
{
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy options
|
||||
foreach (var option in request.Options)
|
||||
{
|
||||
clone.Options.TryAdd(option.Key, option.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
// Note: We don't dispose _httpClient as it's typically managed by DI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Transport abstraction for CLI operations.
|
||||
/// CLI-SDK-62-001: Supports modular transport for online and air-gapped modes.
|
||||
/// </summary>
|
||||
public interface IStellaOpsTransport : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this transport is operating in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
bool IsOffline { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transport mode identifier.
|
||||
/// </summary>
|
||||
string TransportMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an HTTP request and returns the response with streaming content.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for uploading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stream for downloading content.
|
||||
/// </summary>
|
||||
Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transport configuration options.
|
||||
/// </summary>
|
||||
public sealed class TransportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL for the backend API.
|
||||
/// </summary>
|
||||
public string? BackendUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to operate in offline/air-gapped mode.
|
||||
/// </summary>
|
||||
public bool IsOffline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory for offline kit data.
|
||||
/// </summary>
|
||||
public string? OfflineKitDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate SSL certificates.
|
||||
/// </summary>
|
||||
public bool ValidateSsl { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom CA certificate path for SSL validation.
|
||||
/// </summary>
|
||||
public string? CaCertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Proxy URL if required.
|
||||
/// </summary>
|
||||
public string? ProxyUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent string.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-CLI/1.0";
|
||||
}
|
||||
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
186
src/Cli/StellaOps.Cli/Services/Transport/OfflineTransport.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Offline transport implementation for air-gapped mode.
|
||||
/// CLI-SDK-62-001: Provides offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public sealed class OfflineTransport : IStellaOpsTransport
|
||||
{
|
||||
private readonly TransportOptions _options;
|
||||
private readonly ILogger<OfflineTransport> _logger;
|
||||
private readonly string _offlineKitDirectory;
|
||||
private bool _disposed;
|
||||
|
||||
public OfflineTransport(TransportOptions options, ILogger<OfflineTransport> logger)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new ArgumentException("OfflineKitDirectory must be specified for offline transport.", nameof(options));
|
||||
}
|
||||
|
||||
_offlineKitDirectory = options.OfflineKitDirectory;
|
||||
|
||||
if (!Directory.Exists(_offlineKitDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Offline kit directory not found: {_offlineKitDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOffline => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string TransportMode => "offline";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Offline transport handling {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
// Map the request to an offline resource
|
||||
var (found, content) = await TryGetOfflineContentAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (found)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = content,
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
// Return a 503 Service Unavailable for operations that require online access
|
||||
_logger.LogWarning("Operation not available in offline mode: {Method} {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "ERR_AIRGAP_EGRESS_BLOCKED",
|
||||
message = "This operation is not available in offline/air-gapped mode."
|
||||
}
|
||||
})),
|
||||
RequestMessage = request
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetUploadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Create a staging area for uploads in offline mode
|
||||
var stagingDir = Path.Combine(_offlineKitDirectory, "staging", "uploads");
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
var stagingFile = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.dat");
|
||||
_logger.LogDebug("Creating offline upload staging file: {Path}", stagingFile);
|
||||
|
||||
return Task.FromResult<Stream>(File.Create(stagingFile));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> GetDownloadStreamAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// Map endpoint to offline file
|
||||
var localPath = MapEndpointToLocalPath(endpoint);
|
||||
|
||||
if (!File.Exists(localPath))
|
||||
{
|
||||
_logger.LogWarning("Offline resource not found: {Path}", localPath);
|
||||
throw new FileNotFoundException($"Offline resource not found: {endpoint}", localPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Opening offline resource: {Path}", localPath);
|
||||
return Task.FromResult<Stream>(File.OpenRead(localPath));
|
||||
}
|
||||
|
||||
private async Task<(bool Found, HttpContent? Content)> TryGetOfflineContentAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = request.RequestUri;
|
||||
if (uri is null)
|
||||
return (false, null);
|
||||
|
||||
var path = uri.PathAndQuery.TrimStart('/');
|
||||
|
||||
// Check for cached API responses
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", "api", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
// Try with .json extension
|
||||
var jsonPath = cachePath + ".json";
|
||||
if (File.Exists(jsonPath))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new StringContent(content, System.Text.Encoding.UTF8, "application/json"));
|
||||
}
|
||||
|
||||
// Try exact path
|
||||
if (File.Exists(cachePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
// Check for bundled data
|
||||
var bundlePath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(bundlePath))
|
||||
{
|
||||
var content = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
|
||||
return (true, new ByteArrayContent(content));
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private string MapEndpointToLocalPath(string endpoint)
|
||||
{
|
||||
var path = endpoint.TrimStart('/');
|
||||
|
||||
// Check data directory first
|
||||
var dataPath = Path.Combine(_offlineKitDirectory, "data", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(dataPath))
|
||||
return dataPath;
|
||||
|
||||
// Check cache directory
|
||||
var cachePath = Path.Combine(_offlineKitDirectory, "cache", path.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(cachePath))
|
||||
return cachePath;
|
||||
|
||||
// Return data path as default (will throw FileNotFoundException if not found)
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
264
src/Cli/StellaOps.Cli/Services/Transport/StellaOpsClientBase.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Output;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for SDK-generated clients.
|
||||
/// CLI-SDK-62-001: Provides common functionality for SDK clients with modular transport.
|
||||
/// </summary>
|
||||
public abstract class StellaOpsClientBase : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
private readonly IStellaOpsTransport _transport;
|
||||
private readonly ILogger _logger;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization token for API requests.
|
||||
/// </summary>
|
||||
protected string? AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current tenant context.
|
||||
/// </summary>
|
||||
protected string? TenantId { get; set; }
|
||||
|
||||
protected StellaOpsClientBase(IStellaOpsTransport transport, ILogger logger)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the client is operating in offline mode.
|
||||
/// </summary>
|
||||
public bool IsOffline => _transport.IsOffline;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the access token for authenticated requests.
|
||||
/// </summary>
|
||||
public void SetAccessToken(string? token)
|
||||
{
|
||||
AccessToken = token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the tenant context.
|
||||
/// </summary>
|
||||
public void SetTenant(string? tenantId)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the operation requires online connectivity and transport is offline.
|
||||
/// </summary>
|
||||
protected void ThrowIfOffline(string operation)
|
||||
{
|
||||
if (_transport.IsOffline)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Operation '{operation}' is not available in offline/air-gapped mode. " +
|
||||
"Please use online mode or import the required data to the offline kit.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GET request and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> GetAsync<TResponse>(
|
||||
string relativeUrl,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Get, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a POST request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PostAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a PUT request with JSON body and deserializes the response.
|
||||
/// </summary>
|
||||
protected async Task<TResponse?> PutAsync<TRequest, TResponse>(
|
||||
string relativeUrl,
|
||||
TRequest body,
|
||||
CancellationToken cancellationToken)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Put, relativeUrl);
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a DELETE request.
|
||||
/// </summary>
|
||||
protected async Task DeleteAsync(string relativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Delete, relativeUrl);
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request and parses any error response.
|
||||
/// </summary>
|
||||
protected async Task<(TResponse? Result, CliError? Error)> TrySendAsync<TResponse>(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) where TResponse : class
|
||||
{
|
||||
var response = await SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
return (result, null);
|
||||
}
|
||||
|
||||
var error = await ParseErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP request with standard headers.
|
||||
/// </summary>
|
||||
protected HttpRequestMessage CreateRequest(HttpMethod method, string relativeUrl)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, relativeUrl);
|
||||
|
||||
// Add authorization header
|
||||
if (!string.IsNullOrWhiteSpace(AccessToken))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);
|
||||
}
|
||||
|
||||
// Add tenant header
|
||||
if (!string.IsNullOrWhiteSpace(TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-Id", TenantId);
|
||||
}
|
||||
|
||||
// Add standard headers
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request through the transport.
|
||||
/// </summary>
|
||||
protected async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
_logger.LogDebug("Sending {Method} request to {Uri}", request.Method, request.RequestUri);
|
||||
|
||||
return await _transport.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an error response into a CliError.
|
||||
/// </summary>
|
||||
protected async Task<CliError> ParseErrorAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
string? content = null;
|
||||
|
||||
try
|
||||
{
|
||||
content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore content read errors
|
||||
}
|
||||
|
||||
// Try to parse as error envelope
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
try
|
||||
{
|
||||
var envelope = JsonSerializer.Deserialize<ApiErrorEnvelope>(content, JsonOptions);
|
||||
if (envelope?.Error is not null)
|
||||
{
|
||||
return CliError.FromApiErrorEnvelope(envelope, statusCode);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not an error envelope
|
||||
}
|
||||
|
||||
// Try to parse as problem details
|
||||
try
|
||||
{
|
||||
var problem = JsonSerializer.Deserialize<ProblemDocument>(content, JsonOptions);
|
||||
if (problem is not null)
|
||||
{
|
||||
return new CliError(
|
||||
Code: problem.Type ?? $"ERR_HTTP_{statusCode}",
|
||||
Message: problem.Title ?? $"HTTP error {statusCode}",
|
||||
Detail: problem.Detail);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a problem document
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HTTP status-based error
|
||||
return CliError.FromHttpStatus(statusCode, content);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
_transport.Dispose();
|
||||
}
|
||||
}
|
||||
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
126
src/Cli/StellaOps.Cli/Services/Transport/TransportFactory.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
|
||||
namespace StellaOps.Cli.Services.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating transport instances based on configuration.
|
||||
/// CLI-SDK-62-001: Provides modular transport selection for online/offline modes.
|
||||
/// </summary>
|
||||
public sealed class TransportFactory : ITransportFactory
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
private readonly CliProfileManager _profileManager;
|
||||
|
||||
public TransportFactory(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILoggerFactory loggerFactory,
|
||||
StellaOpsCliOptions options,
|
||||
CliProfileManager profileManager)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_profileManager = profileManager ?? throw new ArgumentNullException(nameof(profileManager));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateTransport()
|
||||
{
|
||||
var transportOptions = CreateTransportOptions();
|
||||
|
||||
if (transportOptions.IsOffline)
|
||||
{
|
||||
return CreateOfflineTransport(transportOptions);
|
||||
}
|
||||
|
||||
return CreateHttpTransport(transportOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient("StellaOps");
|
||||
var logger = _loggerFactory.CreateLogger<HttpTransport>();
|
||||
|
||||
return new HttpTransport(httpClient, options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
public IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null)
|
||||
{
|
||||
options ??= CreateTransportOptions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OfflineKitDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Offline kit directory must be specified for offline transport.");
|
||||
}
|
||||
|
||||
var logger = _loggerFactory.CreateLogger<OfflineTransport>();
|
||||
return new OfflineTransport(options, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
public TransportOptions CreateTransportOptions()
|
||||
{
|
||||
var profile = _profileManager.GetCurrentProfileAsync().GetAwaiter().GetResult();
|
||||
|
||||
return new TransportOptions
|
||||
{
|
||||
BackendUrl = profile?.BackendUrl ?? _options.BackendUrl,
|
||||
IsOffline = profile?.IsOffline ?? _options.IsOffline,
|
||||
OfflineKitDirectory = profile?.OfflineKitDirectory ?? _options.OfflineKitDirectory,
|
||||
Timeout = TimeSpan.FromMinutes(5),
|
||||
MaxRetries = 3,
|
||||
ValidateSsl = true,
|
||||
UserAgent = $"StellaOps-CLI/{GetVersion()}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetVersion()
|
||||
{
|
||||
var assembly = typeof(TransportFactory).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version?.ToString() ?? "1.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory interface for creating transport instances.
|
||||
/// </summary>
|
||||
public interface ITransportFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a transport instance based on current configuration.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateTransport();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP transport for online operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateHttpTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline transport for air-gapped operations.
|
||||
/// </summary>
|
||||
IStellaOpsTransport CreateOfflineTransport(TransportOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates transport options from current configuration.
|
||||
/// </summary>
|
||||
TransportOptions CreateTransportOptions();
|
||||
}
|
||||
228
src/Cli/StellaOps.Cli/Services/VexObservationsClient.cs
Normal file
228
src/Cli/StellaOps.Cli/Services/VexObservationsClient.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
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.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for VEX observation queries.
|
||||
/// Per CLI-LNM-22-002.
|
||||
/// </summary>
|
||||
internal sealed class VexObservationsClient : IVexObservationsClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenClient? _tokenClient;
|
||||
private readonly ILogger<VexObservationsClient> _logger;
|
||||
private string? _cachedToken;
|
||||
private DateTimeOffset _tokenExpiry;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public VexObservationsClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<VexObservationsClient> logger,
|
||||
ITokenClient? tokenClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_tokenClient = tokenClient;
|
||||
}
|
||||
|
||||
public async Task<VexObservationResponse> GetObservationsAsync(
|
||||
VexObservationQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = BuildObservationRequestUri(query);
|
||||
_logger.LogDebug("Fetching VEX observations from {Uri}", requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX observations request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX observations: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexObservationResponse>(content, SerializerOptions)
|
||||
?? new VexObservationResponse();
|
||||
}
|
||||
|
||||
public async Task<VexLinksetResponse> GetLinksetAsync(
|
||||
VexLinksetQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = BuildLinksetRequestUri(query);
|
||||
_logger.LogDebug("Fetching VEX linkset from {Uri}", requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX linkset request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX linkset: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexLinksetResponse>(content, SerializerOptions)
|
||||
?? new VexLinksetResponse();
|
||||
}
|
||||
|
||||
public async Task<VexObservation?> GetObservationByIdAsync(
|
||||
string tenant,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
await EnsureAuthorizationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var requestUri = $"api/v1/tenants/{Uri.EscapeDataString(tenant)}/vex/observations/{Uri.EscapeDataString(observationId)}";
|
||||
_logger.LogDebug("Fetching VEX observation {ObservationId} from {Uri}", observationId, requestUri);
|
||||
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("VEX observation request failed: {StatusCode} - {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
throw new HttpRequestException($"Failed to fetch VEX observation: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<VexObservation>(content, SerializerOptions);
|
||||
}
|
||||
|
||||
private async Task EnsureAuthorizationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tokenClient is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_cachedToken) && DateTimeOffset.UtcNow < _tokenExpiry)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenResult = await _tokenClient.GetAccessTokenAsync(
|
||||
new[] { StellaOpsScopes.VexRead },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (tokenResult.IsSuccess && !string.IsNullOrWhiteSpace(tokenResult.AccessToken))
|
||||
{
|
||||
_cachedToken = tokenResult.AccessToken;
|
||||
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _cachedToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to acquire token for VEX API access.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildObservationRequestUri(VexObservationQuery query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/observations?");
|
||||
|
||||
foreach (var vulnId in query.VulnerabilityIds)
|
||||
{
|
||||
sb.Append($"vulnerabilityId={Uri.EscapeDataString(vulnId)}&");
|
||||
}
|
||||
|
||||
foreach (var productKey in query.ProductKeys)
|
||||
{
|
||||
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
|
||||
}
|
||||
|
||||
foreach (var purl in query.Purls)
|
||||
{
|
||||
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
|
||||
}
|
||||
|
||||
foreach (var cpe in query.Cpes)
|
||||
{
|
||||
sb.Append($"cpe={Uri.EscapeDataString(cpe)}&");
|
||||
}
|
||||
|
||||
foreach (var status in query.Statuses)
|
||||
{
|
||||
sb.Append($"status={Uri.EscapeDataString(status)}&");
|
||||
}
|
||||
|
||||
foreach (var providerId in query.ProviderIds)
|
||||
{
|
||||
sb.Append($"providerId={Uri.EscapeDataString(providerId)}&");
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
sb.Append($"limit={query.Limit.Value}&");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Cursor))
|
||||
{
|
||||
sb.Append($"cursor={Uri.EscapeDataString(query.Cursor)}&");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd('&', '?');
|
||||
}
|
||||
|
||||
private static string BuildLinksetRequestUri(VexLinksetQuery query)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"api/v1/tenants/{Uri.EscapeDataString(query.Tenant)}/vex/linkset/{Uri.EscapeDataString(query.VulnerabilityId)}?");
|
||||
|
||||
foreach (var productKey in query.ProductKeys)
|
||||
{
|
||||
sb.Append($"productKey={Uri.EscapeDataString(productKey)}&");
|
||||
}
|
||||
|
||||
foreach (var purl in query.Purls)
|
||||
{
|
||||
sb.Append($"purl={Uri.EscapeDataString(purl)}&");
|
||||
}
|
||||
|
||||
foreach (var status in query.Statuses)
|
||||
{
|
||||
sb.Append($"status={Uri.EscapeDataString(status)}&");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd('&', '?');
|
||||
}
|
||||
}
|
||||
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
192
src/Cli/StellaOps.Cli/Telemetry/TraceparentHttpMessageHandler.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Cli.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP message handler that propagates W3C Trace Context (traceparent) headers.
|
||||
/// Per CLI-OBS-50-001, ensures CLI HTTP client propagates traceparent headers for all commands,
|
||||
/// prints correlation IDs on failure, and records trace IDs in verbose logs.
|
||||
/// </summary>
|
||||
public sealed class TraceparentHttpMessageHandler : DelegatingHandler
|
||||
{
|
||||
private const string TraceparentHeader = "traceparent";
|
||||
private const string TracestateHeader = "tracestate";
|
||||
private const string RequestIdHeader = "x-request-id";
|
||||
private const string CorrelationIdHeader = "x-correlation-id";
|
||||
|
||||
private readonly ILogger<TraceparentHttpMessageHandler> _logger;
|
||||
private readonly bool _verbose;
|
||||
|
||||
public TraceparentHttpMessageHandler(
|
||||
ILogger<TraceparentHttpMessageHandler> logger,
|
||||
bool verbose = false)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_verbose = verbose;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
string? traceId = null;
|
||||
string? spanId = null;
|
||||
|
||||
// Generate or use existing trace context
|
||||
if (activity is not null)
|
||||
{
|
||||
traceId = activity.TraceId.ToString();
|
||||
spanId = activity.SpanId.ToString();
|
||||
|
||||
// Add W3C traceparent header if not already present
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-{(activity.Recorded ? "01" : "00")}";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Added traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tracestate if present
|
||||
if (!string.IsNullOrWhiteSpace(activity.TraceStateString) &&
|
||||
!request.Headers.Contains(TracestateHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TracestateHeader, activity.TraceStateString);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate a new trace ID if no activity exists
|
||||
traceId = Guid.NewGuid().ToString("N");
|
||||
spanId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
if (!request.Headers.Contains(TraceparentHeader))
|
||||
{
|
||||
var traceparent = $"00-{traceId}-{spanId}-00";
|
||||
request.Headers.TryAddWithoutValidation(TraceparentHeader, traceparent);
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug("Generated new traceparent header: {Traceparent}", traceparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also add x-request-id for legacy compatibility
|
||||
if (!request.Headers.Contains(RequestIdHeader))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(RequestIdHeader, traceId);
|
||||
}
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Sending {Method} {Uri} with trace_id={TraceId}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Request failed: {Method} {Uri} trace_id={TraceId} error={Error}",
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
ex.Message);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Extract correlation ID from response if present
|
||||
var responseTraceId = GetResponseTraceId(response);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Request returned {StatusCode}: {Method} {Uri} trace_id={TraceId} response_trace_id={ResponseTraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId,
|
||||
responseTraceId ?? "(not provided)");
|
||||
}
|
||||
else if (_verbose)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Request completed {StatusCode}: {Method} {Uri} trace_id={TraceId}",
|
||||
(int)response.StatusCode,
|
||||
request.Method,
|
||||
ScrubUrl(request.RequestUri),
|
||||
traceId);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string? GetResponseTraceId(HttpResponseMessage response)
|
||||
{
|
||||
if (response.Headers.TryGetValues(CorrelationIdHeader, out var correlationValues))
|
||||
{
|
||||
return string.Join(",", correlationValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues(RequestIdHeader, out var requestIdValues))
|
||||
{
|
||||
return string.Join(",", requestIdValues);
|
||||
}
|
||||
|
||||
if (response.Headers.TryGetValues("x-trace-id", out var traceIdValues))
|
||||
{
|
||||
return string.Join(",", traceIdValues);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ScrubUrl(Uri? uri)
|
||||
{
|
||||
if (uri is null)
|
||||
return "(null)";
|
||||
|
||||
// Remove query string to avoid logging sensitive parameters
|
||||
return $"{uri.Scheme}://{uri.Authority}{uri.AbsolutePath}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension to add traceparent propagation to HTTP client.
|
||||
/// </summary>
|
||||
public static class TraceparentHttpClientBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds W3C Trace Context (traceparent) header propagation to the HTTP client.
|
||||
/// Per CLI-OBS-50-001.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddTraceparentPropagation(
|
||||
this IHttpClientBuilder builder,
|
||||
bool verbose = false)
|
||||
{
|
||||
return builder.AddHttpMessageHandler(sp =>
|
||||
{
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
return new TraceparentHttpMessageHandler(
|
||||
loggerFactory.CreateLogger<TraceparentHttpMessageHandler>(),
|
||||
verbose);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IAdvisoryFieldChangeEmitter"/>.
|
||||
/// Per CONCELIER-RISK-69-001, emits notifications on upstream advisory field changes
|
||||
/// with observation IDs and provenance; no severity inference.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryFieldChangeEmitter : IAdvisoryFieldChangeEmitter
|
||||
{
|
||||
private readonly IAdvisoryFieldChangeNotificationPublisher _publisher;
|
||||
private readonly ILogger<AdvisoryFieldChangeEmitter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AdvisoryFieldChangeEmitter(
|
||||
IAdvisoryFieldChangeNotificationPublisher publisher,
|
||||
ILogger<AdvisoryFieldChangeEmitter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AdvisoryFieldChangeNotification?> EmitChangesAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal,
|
||||
string? linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
ArgumentNullException.ThrowIfNull(currentSignal);
|
||||
|
||||
var changes = DetectChanges(previousSignal, currentSignal);
|
||||
|
||||
if (changes.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No field changes detected for observation {ObservationId}",
|
||||
observationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var changeType = DetermineChangeType(changes);
|
||||
var provenance = BuildProvenance(previousSignal, currentSignal);
|
||||
|
||||
var notification = new AdvisoryFieldChangeNotification(
|
||||
NotificationId: Guid.NewGuid(),
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: currentSignal.AdvisoryId,
|
||||
ObservationId: observationId,
|
||||
LinksetId: linksetId,
|
||||
ChangeType: changeType,
|
||||
Changes: changes,
|
||||
Provenance: provenance,
|
||||
DetectedAt: now,
|
||||
EmittedAt: now);
|
||||
|
||||
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted field change notification for observation {ObservationId}: type={ChangeType}, fields=[{Fields}]",
|
||||
observationId, changeType, string.Join(", ", notification.ChangedFields));
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AdvisoryFieldChangeNotification>> EmitLinksetChangesAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
IReadOnlyList<VendorRiskSignal> previousSignals,
|
||||
IReadOnlyList<VendorRiskSignal> currentSignals,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
|
||||
ArgumentNullException.ThrowIfNull(currentSignals);
|
||||
|
||||
var previousByObservation = (previousSignals ?? [])
|
||||
.ToDictionary(s => s.ObservationId, StringComparer.Ordinal);
|
||||
|
||||
var notifications = new List<AdvisoryFieldChangeNotification>();
|
||||
|
||||
foreach (var currentSignal in currentSignals)
|
||||
{
|
||||
previousByObservation.TryGetValue(currentSignal.ObservationId, out var previousSignal);
|
||||
|
||||
var notification = await EmitChangesAsync(
|
||||
tenantId,
|
||||
currentSignal.ObservationId,
|
||||
previousSignal,
|
||||
currentSignal,
|
||||
linksetId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (notification is not null)
|
||||
{
|
||||
notifications.Add(notification);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for withdrawn observations
|
||||
var currentObservationIds = currentSignals
|
||||
.Select(s => s.ObservationId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var previousSignal in previousSignals ?? [])
|
||||
{
|
||||
if (!currentObservationIds.Contains(previousSignal.ObservationId))
|
||||
{
|
||||
var withdrawnNotification = await EmitWithdrawnAsync(
|
||||
tenantId,
|
||||
previousSignal,
|
||||
linksetId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (withdrawnNotification is not null)
|
||||
{
|
||||
notifications.Add(withdrawnNotification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
private async Task<AdvisoryFieldChangeNotification?> EmitWithdrawnAsync(
|
||||
string tenantId,
|
||||
VendorRiskSignal previousSignal,
|
||||
string? linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var provenance = new AdvisoryFieldChangeProvenance(
|
||||
Vendor: previousSignal.Provenance.Vendor,
|
||||
Source: previousSignal.Provenance.Source,
|
||||
ObservationHash: previousSignal.Provenance.ObservationHash,
|
||||
FetchedAt: previousSignal.Provenance.FetchedAt,
|
||||
IngestJobId: previousSignal.Provenance.IngestJobId,
|
||||
UpstreamId: previousSignal.Provenance.UpstreamId,
|
||||
PreviousObservationHash: null);
|
||||
|
||||
var change = new AdvisoryFieldChange(
|
||||
Field: "observation_status",
|
||||
PreviousValue: "active",
|
||||
CurrentValue: "withdrawn",
|
||||
Category: AdvisoryFieldChangeCategory.Metadata,
|
||||
Provenance: provenance);
|
||||
|
||||
var notification = new AdvisoryFieldChangeNotification(
|
||||
NotificationId: Guid.NewGuid(),
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: previousSignal.AdvisoryId,
|
||||
ObservationId: previousSignal.ObservationId,
|
||||
LinksetId: linksetId,
|
||||
ChangeType: AdvisoryFieldChangeType.ObservationWithdrawn,
|
||||
Changes: [change],
|
||||
Provenance: provenance,
|
||||
DetectedAt: now,
|
||||
EmittedAt: now);
|
||||
|
||||
await _publisher.PublishAsync(notification, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitted withdrawn observation notification for {ObservationId}",
|
||||
previousSignal.ObservationId);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
private static ImmutableArray<AdvisoryFieldChange> DetectChanges(
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal)
|
||||
{
|
||||
var changes = ImmutableArray.CreateBuilder<AdvisoryFieldChange>();
|
||||
var currentProvenance = MapProvenance(currentSignal.Provenance, previousSignal?.Provenance.ObservationHash);
|
||||
|
||||
// New observation
|
||||
if (previousSignal is null)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "observation_status",
|
||||
PreviousValue: null,
|
||||
CurrentValue: "active",
|
||||
Category: AdvisoryFieldChangeCategory.Metadata,
|
||||
Provenance: currentProvenance));
|
||||
|
||||
// Report initial fix availability if present
|
||||
if (currentSignal.HasFixAvailable)
|
||||
{
|
||||
var fixVersion = currentSignal.FixAvailability.FirstOrDefault(f => f.Status == FixStatus.Available)?.FixedVersion;
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "fix_availability",
|
||||
PreviousValue: null,
|
||||
CurrentValue: fixVersion ?? "available",
|
||||
Category: AdvisoryFieldChangeCategory.Remediation,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Report initial KEV status if present
|
||||
if (currentSignal.IsKnownExploited)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "kev_status",
|
||||
PreviousValue: null,
|
||||
CurrentValue: "in_kev",
|
||||
Category: AdvisoryFieldChangeCategory.Threat,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Report initial severity if present
|
||||
if (currentSignal.HighestCvssScore is not null)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "severity",
|
||||
PreviousValue: null,
|
||||
CurrentValue: currentSignal.HighestCvssScore.EffectiveSeverity,
|
||||
Category: AdvisoryFieldChangeCategory.Risk,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
return changes.ToImmutable();
|
||||
}
|
||||
|
||||
// Compare fix availability
|
||||
var previousHasFix = previousSignal.HasFixAvailable;
|
||||
var currentHasFix = currentSignal.HasFixAvailable;
|
||||
|
||||
if (previousHasFix != currentHasFix)
|
||||
{
|
||||
var previousValue = previousHasFix ? GetFixVersion(previousSignal) ?? "available" : "not_available";
|
||||
var currentValue = currentHasFix ? GetFixVersion(currentSignal) ?? "available" : "not_available";
|
||||
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "fix_availability",
|
||||
PreviousValue: previousValue,
|
||||
CurrentValue: currentValue,
|
||||
Category: AdvisoryFieldChangeCategory.Remediation,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
else if (currentHasFix)
|
||||
{
|
||||
// Both have fixes - check if version changed
|
||||
var previousVersion = GetFixVersion(previousSignal);
|
||||
var currentVersion = GetFixVersion(currentSignal);
|
||||
|
||||
if (!string.Equals(previousVersion, currentVersion, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "fix_version",
|
||||
PreviousValue: previousVersion,
|
||||
CurrentValue: currentVersion,
|
||||
Category: AdvisoryFieldChangeCategory.Remediation,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
}
|
||||
|
||||
// Compare KEV status
|
||||
var previousInKev = previousSignal.IsKnownExploited;
|
||||
var currentInKev = currentSignal.IsKnownExploited;
|
||||
|
||||
if (previousInKev != currentInKev)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "kev_status",
|
||||
PreviousValue: previousInKev ? "in_kev" : "not_in_kev",
|
||||
CurrentValue: currentInKev ? "in_kev" : "not_in_kev",
|
||||
Category: AdvisoryFieldChangeCategory.Threat,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Compare severity
|
||||
var previousSeverity = previousSignal.HighestCvssScore?.EffectiveSeverity;
|
||||
var currentSeverity = currentSignal.HighestCvssScore?.EffectiveSeverity;
|
||||
|
||||
if (!string.Equals(previousSeverity, currentSeverity, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "severity",
|
||||
PreviousValue: previousSeverity,
|
||||
CurrentValue: currentSeverity,
|
||||
Category: AdvisoryFieldChangeCategory.Risk,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
// Compare CVSS score (if both have scores)
|
||||
var previousScore = previousSignal.HighestCvssScore?.Score;
|
||||
var currentScore = currentSignal.HighestCvssScore?.Score;
|
||||
|
||||
if (previousScore.HasValue && currentScore.HasValue &&
|
||||
Math.Abs(previousScore.Value - currentScore.Value) >= 0.1)
|
||||
{
|
||||
changes.Add(new AdvisoryFieldChange(
|
||||
Field: "cvss_score",
|
||||
PreviousValue: previousScore.Value.ToString("F1"),
|
||||
CurrentValue: currentScore.Value.ToString("F1"),
|
||||
Category: AdvisoryFieldChangeCategory.Risk,
|
||||
Provenance: currentProvenance));
|
||||
}
|
||||
|
||||
return changes.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? GetFixVersion(VendorRiskSignal signal)
|
||||
{
|
||||
return signal.FixAvailability
|
||||
.Where(f => f.Status == FixStatus.Available)
|
||||
.Select(f => f.FixedVersion)
|
||||
.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||
}
|
||||
|
||||
private static AdvisoryFieldChangeType DetermineChangeType(ImmutableArray<AdvisoryFieldChange> changes)
|
||||
{
|
||||
if (changes.Length == 0)
|
||||
{
|
||||
return AdvisoryFieldChangeType.Unknown;
|
||||
}
|
||||
|
||||
if (changes.Length > 1)
|
||||
{
|
||||
return AdvisoryFieldChangeType.MultipleChanges;
|
||||
}
|
||||
|
||||
var change = changes[0];
|
||||
|
||||
return change.Field switch
|
||||
{
|
||||
"observation_status" when change.CurrentValue == "active" => AdvisoryFieldChangeType.NewObservation,
|
||||
"observation_status" when change.CurrentValue == "withdrawn" => AdvisoryFieldChangeType.ObservationWithdrawn,
|
||||
"fix_availability" or "fix_version" => AdvisoryFieldChangeType.FixAvailabilityChanged,
|
||||
"kev_status" => AdvisoryFieldChangeType.KevStatusChanged,
|
||||
"severity" or "cvss_score" => AdvisoryFieldChangeType.SeverityChanged,
|
||||
_ => AdvisoryFieldChangeType.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvisoryFieldChangeProvenance BuildProvenance(
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal)
|
||||
{
|
||||
return new AdvisoryFieldChangeProvenance(
|
||||
Vendor: currentSignal.Provenance.Vendor,
|
||||
Source: currentSignal.Provenance.Source,
|
||||
ObservationHash: currentSignal.Provenance.ObservationHash,
|
||||
FetchedAt: currentSignal.Provenance.FetchedAt,
|
||||
IngestJobId: currentSignal.Provenance.IngestJobId,
|
||||
UpstreamId: currentSignal.Provenance.UpstreamId,
|
||||
PreviousObservationHash: previousSignal?.Provenance.ObservationHash);
|
||||
}
|
||||
|
||||
private static AdvisoryFieldChangeProvenance MapProvenance(
|
||||
VendorRiskProvenance provenance,
|
||||
string? previousHash)
|
||||
{
|
||||
return new AdvisoryFieldChangeProvenance(
|
||||
Vendor: provenance.Vendor,
|
||||
Source: provenance.Source,
|
||||
ObservationHash: provenance.ObservationHash,
|
||||
FetchedAt: provenance.FetchedAt,
|
||||
IngestJobId: provenance.IngestJobId,
|
||||
UpstreamId: provenance.UpstreamId,
|
||||
PreviousObservationHash: previousHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Notification for upstream advisory field changes.
|
||||
/// Per CONCELIER-RISK-69-001, emits notifications on field changes (e.g., fix availability)
|
||||
/// with observation IDs and provenance; no severity inference.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This notification is fact-only: surfaces vendor-published changes with provenance.
|
||||
/// No inference, weighting, or exploitability assumptions.
|
||||
/// </remarks>
|
||||
public sealed record AdvisoryFieldChangeNotification(
|
||||
Guid NotificationId,
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string ObservationId,
|
||||
string? LinksetId,
|
||||
AdvisoryFieldChangeType ChangeType,
|
||||
ImmutableArray<AdvisoryFieldChange> Changes,
|
||||
AdvisoryFieldChangeProvenance Provenance,
|
||||
DateTimeOffset DetectedAt,
|
||||
DateTimeOffset EmittedAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Event kind for notification routing.
|
||||
/// </summary>
|
||||
public const string EventKind = "advisory.field.changed";
|
||||
|
||||
/// <summary>
|
||||
/// Event version.
|
||||
/// </summary>
|
||||
public const string EventVersion = "1";
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if any critical field changed (fix availability, KEV status).
|
||||
/// </summary>
|
||||
public bool HasCriticalChange => Changes.Any(c =>
|
||||
c.Field is "fix_availability" or "kev_status" or "severity");
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique fields that changed.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ChangedFields =>
|
||||
Changes.Select(c => c.Field).Distinct(StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of field change notification.
|
||||
/// </summary>
|
||||
public enum AdvisoryFieldChangeType
|
||||
{
|
||||
/// <summary>Unknown change type.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Fix availability changed (became available, version updated, etc.).</summary>
|
||||
FixAvailabilityChanged,
|
||||
|
||||
/// <summary>KEV status changed (added to or removed from KEV list).</summary>
|
||||
KevStatusChanged,
|
||||
|
||||
/// <summary>Severity score changed.</summary>
|
||||
SeverityChanged,
|
||||
|
||||
/// <summary>New observation added from upstream.</summary>
|
||||
NewObservation,
|
||||
|
||||
/// <summary>Observation withdrawn by upstream.</summary>
|
||||
ObservationWithdrawn,
|
||||
|
||||
/// <summary>Advisory link/reference added.</summary>
|
||||
ReferenceAdded,
|
||||
|
||||
/// <summary>Multiple fields changed.</summary>
|
||||
MultipleChanges
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A specific field change in an advisory.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryFieldChange(
|
||||
string Field,
|
||||
string? PreviousValue,
|
||||
string? CurrentValue,
|
||||
AdvisoryFieldChangeCategory Category,
|
||||
AdvisoryFieldChangeProvenance Provenance)
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates if the value transitioned from null/empty to having a value.
|
||||
/// </summary>
|
||||
public bool IsNewValue => string.IsNullOrWhiteSpace(PreviousValue) && !string.IsNullOrWhiteSpace(CurrentValue);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the value was removed (non-null to null/empty).
|
||||
/// </summary>
|
||||
public bool IsValueRemoved => !string.IsNullOrWhiteSpace(PreviousValue) && string.IsNullOrWhiteSpace(CurrentValue);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the value changed between two non-null values.
|
||||
/// </summary>
|
||||
public bool IsValueUpdated => !string.IsNullOrWhiteSpace(PreviousValue) &&
|
||||
!string.IsNullOrWhiteSpace(CurrentValue) &&
|
||||
!string.Equals(PreviousValue, CurrentValue, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category of field change for filtering/routing.
|
||||
/// </summary>
|
||||
public enum AdvisoryFieldChangeCategory
|
||||
{
|
||||
/// <summary>Unknown category.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Remediation-related (fix version, patch URL, etc.).</summary>
|
||||
Remediation,
|
||||
|
||||
/// <summary>Threat-related (KEV, exploitation evidence).</summary>
|
||||
Threat,
|
||||
|
||||
/// <summary>Risk-related (severity, CVSS score).</summary>
|
||||
Risk,
|
||||
|
||||
/// <summary>Metadata-related (references, aliases, description).</summary>
|
||||
Metadata,
|
||||
|
||||
/// <summary>Scope-related (affected packages, versions).</summary>
|
||||
Scope
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance anchor for field change notifications.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryFieldChangeProvenance(
|
||||
string Vendor,
|
||||
string Source,
|
||||
string ObservationHash,
|
||||
DateTimeOffset FetchedAt,
|
||||
string? IngestJobId,
|
||||
string? UpstreamId,
|
||||
string? PreviousObservationHash);
|
||||
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IFixAvailabilityEmitter"/>.
|
||||
/// Per CONCELIER-RISK-66-002, emits structured fix-availability metadata per observation/linkset
|
||||
/// without guessing exploitability.
|
||||
/// </summary>
|
||||
public sealed class FixAvailabilityEmitter : IFixAvailabilityEmitter
|
||||
{
|
||||
private readonly IVendorRiskSignalProvider _riskSignalProvider;
|
||||
private readonly ILogger<FixAvailabilityEmitter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public FixAvailabilityEmitter(
|
||||
IVendorRiskSignalProvider riskSignalProvider,
|
||||
ILogger<FixAvailabilityEmitter> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_riskSignalProvider = riskSignalProvider ?? throw new ArgumentNullException(nameof(riskSignalProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FixAvailabilityMetadata?> EmitByObservationAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(observationId);
|
||||
|
||||
var riskSignal = await _riskSignalProvider.GetByObservationAsync(
|
||||
tenantId, observationId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (riskSignal is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No risk signal found for observation {ObservationId} in tenant {TenantId}",
|
||||
observationId, tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return EmitFromRiskSignal(riskSignal, linksetId: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
|
||||
|
||||
var riskSignals = await _riskSignalProvider.GetByLinksetAsync(
|
||||
tenantId, linksetId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (riskSignals.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No risk signals found for linkset {LinksetId} in tenant {TenantId}",
|
||||
linksetId, tenantId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<FixAvailabilityMetadata>(riskSignals.Count);
|
||||
foreach (var signal in riskSignals)
|
||||
{
|
||||
results.Add(EmitFromRiskSignal(signal, linksetId));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
|
||||
|
||||
var riskSignals = await _riskSignalProvider.GetByAdvisoryAsync(
|
||||
tenantId, advisoryId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (riskSignals.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No risk signals found for advisory {AdvisoryId} in tenant {TenantId}",
|
||||
advisoryId, tenantId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<FixAvailabilityMetadata>(riskSignals.Count);
|
||||
foreach (var signal in riskSignals)
|
||||
{
|
||||
results.Add(EmitFromRiskSignal(signal, linksetId: null));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private FixAvailabilityMetadata EmitFromRiskSignal(VendorRiskSignal signal, string? linksetId)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var provenance = MapProvenance(signal.Provenance);
|
||||
|
||||
if (signal.FixAvailability.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Emitting empty fix-availability for observation {ObservationId} (no fix data in signal)",
|
||||
signal.ObservationId);
|
||||
|
||||
return FixAvailabilityMetadata.Empty(
|
||||
signal.TenantId,
|
||||
signal.AdvisoryId,
|
||||
signal.ObservationId,
|
||||
linksetId,
|
||||
provenance,
|
||||
now);
|
||||
}
|
||||
|
||||
var releases = MapReleases(signal.FixAvailability, provenance);
|
||||
var advisoryLinks = ExtractAdvisoryLinks(signal.FixAvailability, provenance);
|
||||
var status = DetermineOverallStatus(signal.FixAvailability);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Emitting fix-availability for observation {ObservationId}: status={Status}, releases={ReleaseCount}, links={LinkCount}",
|
||||
signal.ObservationId, status, releases.Length, advisoryLinks.Length);
|
||||
|
||||
return new FixAvailabilityMetadata(
|
||||
TenantId: signal.TenantId,
|
||||
AdvisoryId: signal.AdvisoryId,
|
||||
ObservationId: signal.ObservationId,
|
||||
LinksetId: linksetId,
|
||||
Status: status,
|
||||
Releases: releases,
|
||||
AdvisoryLinks: advisoryLinks,
|
||||
Provenance: provenance,
|
||||
EmittedAt: now);
|
||||
}
|
||||
|
||||
private static FixAvailabilityProvenance MapProvenance(VendorRiskProvenance vendorProvenance)
|
||||
{
|
||||
return new FixAvailabilityProvenance(
|
||||
Vendor: vendorProvenance.Vendor,
|
||||
Source: vendorProvenance.Source,
|
||||
ObservationHash: vendorProvenance.ObservationHash,
|
||||
FetchedAt: vendorProvenance.FetchedAt,
|
||||
IngestJobId: vendorProvenance.IngestJobId,
|
||||
UpstreamId: vendorProvenance.UpstreamId);
|
||||
}
|
||||
|
||||
private static ImmutableArray<FixRelease> MapReleases(
|
||||
ImmutableArray<VendorFixAvailability> vendorFixes,
|
||||
FixAvailabilityProvenance provenance)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<FixRelease>();
|
||||
|
||||
foreach (var vendorFix in vendorFixes)
|
||||
{
|
||||
if (vendorFix.Status != FixStatus.Available || string.IsNullOrWhiteSpace(vendorFix.FixedVersion))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fixProvenance = MapProvenance(vendorFix.Provenance);
|
||||
var releaseType = DetermineReleaseType(vendorFix.FixedVersion);
|
||||
|
||||
builder.Add(new FixRelease(
|
||||
FixedVersion: vendorFix.FixedVersion,
|
||||
Package: vendorFix.Package,
|
||||
Ecosystem: vendorFix.Ecosystem,
|
||||
ReleasedAt: vendorFix.FixReleasedAt,
|
||||
Type: releaseType,
|
||||
Provenance: fixProvenance));
|
||||
}
|
||||
|
||||
// Sort by release date, then by version for deterministic ordering
|
||||
return builder
|
||||
.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(r => r.FixedVersion, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<FixAdvisoryLink> ExtractAdvisoryLinks(
|
||||
ImmutableArray<VendorFixAvailability> vendorFixes,
|
||||
FixAvailabilityProvenance provenance)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<FixAdvisoryLink>();
|
||||
var seenUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var vendorFix in vendorFixes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vendorFix.AdvisoryUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenUrls.Add(vendorFix.AdvisoryUrl))
|
||||
{
|
||||
continue; // Deduplicate
|
||||
}
|
||||
|
||||
var fixProvenance = MapProvenance(vendorFix.Provenance);
|
||||
var linkType = DetermineLinkType(vendorFix.AdvisoryUrl);
|
||||
|
||||
builder.Add(new FixAdvisoryLink(
|
||||
Url: vendorFix.AdvisoryUrl,
|
||||
Title: null, // Not available from VendorFixAvailability
|
||||
Type: linkType,
|
||||
PublishedAt: vendorFix.FixReleasedAt,
|
||||
Provenance: fixProvenance));
|
||||
}
|
||||
|
||||
// Sort by published date for deterministic ordering
|
||||
return builder
|
||||
.OrderBy(l => l.PublishedAt ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(l => l.Url, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static FixAvailabilityStatus DetermineOverallStatus(
|
||||
ImmutableArray<VendorFixAvailability> vendorFixes)
|
||||
{
|
||||
if (vendorFixes.IsDefaultOrEmpty)
|
||||
{
|
||||
return FixAvailabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
// Check for available fixes first
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.Available))
|
||||
{
|
||||
return FixAvailabilityStatus.Available;
|
||||
}
|
||||
|
||||
// Check for in-progress fixes
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.InProgress))
|
||||
{
|
||||
return FixAvailabilityStatus.InProgress;
|
||||
}
|
||||
|
||||
// Check for will-not-fix
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.WillNotFix))
|
||||
{
|
||||
return FixAvailabilityStatus.WillNotFix;
|
||||
}
|
||||
|
||||
// Check for not-available
|
||||
if (vendorFixes.Any(f => f.Status == FixStatus.NotAvailable))
|
||||
{
|
||||
return FixAvailabilityStatus.NotAvailable;
|
||||
}
|
||||
|
||||
return FixAvailabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
private static FixReleaseType DetermineReleaseType(string version)
|
||||
{
|
||||
// Basic heuristics for release type
|
||||
var lowerVersion = version.ToLowerInvariant();
|
||||
|
||||
if (lowerVersion.Contains("hotfix") || lowerVersion.Contains("patch"))
|
||||
{
|
||||
return FixReleaseType.Hotfix;
|
||||
}
|
||||
|
||||
if (lowerVersion.Contains("backport"))
|
||||
{
|
||||
return FixReleaseType.Backport;
|
||||
}
|
||||
|
||||
// Could add SemVer analysis here for minor/major detection
|
||||
// For now, default to Patch as most security fixes are patches
|
||||
return FixReleaseType.Patch;
|
||||
}
|
||||
|
||||
private static FixAdvisoryLinkType DetermineLinkType(string url)
|
||||
{
|
||||
var lowerUrl = url.ToLowerInvariant();
|
||||
|
||||
// Distribution security notices
|
||||
if (lowerUrl.Contains("access.redhat.com/errata") ||
|
||||
lowerUrl.Contains("ubuntu.com/security") ||
|
||||
lowerUrl.Contains("debian.org/security") ||
|
||||
lowerUrl.Contains("suse.com/security"))
|
||||
{
|
||||
return FixAdvisoryLinkType.DistributionNotice;
|
||||
}
|
||||
|
||||
// Commit references
|
||||
if (lowerUrl.Contains("/commit/") ||
|
||||
lowerUrl.Contains("/commits/"))
|
||||
{
|
||||
return FixAdvisoryLinkType.Commit;
|
||||
}
|
||||
|
||||
// Patch URLs
|
||||
if (lowerUrl.EndsWith(".patch") ||
|
||||
lowerUrl.Contains("/patches/") ||
|
||||
lowerUrl.Contains("/diff/"))
|
||||
{
|
||||
return FixAdvisoryLinkType.PatchUrl;
|
||||
}
|
||||
|
||||
// Release notes
|
||||
if (lowerUrl.Contains("/releases/") ||
|
||||
lowerUrl.Contains("/release-notes") ||
|
||||
lowerUrl.Contains("/changelog"))
|
||||
{
|
||||
return FixAdvisoryLinkType.ReleaseNotes;
|
||||
}
|
||||
|
||||
// Vendor advisories (common patterns)
|
||||
if (lowerUrl.Contains("/security/") ||
|
||||
lowerUrl.Contains("/advisory/") ||
|
||||
lowerUrl.Contains("/cve-") ||
|
||||
lowerUrl.Contains("/vuln"))
|
||||
{
|
||||
return FixAdvisoryLinkType.VendorAdvisory;
|
||||
}
|
||||
|
||||
// GitHub Security Advisories
|
||||
if (lowerUrl.Contains("github.com") && lowerUrl.Contains("/advisories/"))
|
||||
{
|
||||
return FixAdvisoryLinkType.UpstreamAdvisory;
|
||||
}
|
||||
|
||||
return FixAdvisoryLinkType.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Structured fix-availability metadata per observation/linkset.
|
||||
/// Per CONCELIER-RISK-66-002, emits release version, advisory link, and evidence timestamp
|
||||
/// without guessing exploitability.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This model is fact-only: surfaces vendor-published fix information with provenance.
|
||||
/// No inference, weighting, or exploitability assumptions.
|
||||
/// </remarks>
|
||||
public sealed record FixAvailabilityMetadata(
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string ObservationId,
|
||||
string? LinksetId,
|
||||
FixAvailabilityStatus Status,
|
||||
ImmutableArray<FixRelease> Releases,
|
||||
ImmutableArray<FixAdvisoryLink> AdvisoryLinks,
|
||||
FixAvailabilityProvenance Provenance,
|
||||
DateTimeOffset EmittedAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an empty fix-availability metadata for observations without fix data.
|
||||
/// </summary>
|
||||
public static FixAvailabilityMetadata Empty(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
string observationId,
|
||||
string? linksetId,
|
||||
FixAvailabilityProvenance provenance,
|
||||
DateTimeOffset emittedAt)
|
||||
{
|
||||
return new FixAvailabilityMetadata(
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: advisoryId,
|
||||
ObservationId: observationId,
|
||||
LinksetId: linksetId,
|
||||
Status: FixAvailabilityStatus.Unknown,
|
||||
Releases: ImmutableArray<FixRelease>.Empty,
|
||||
AdvisoryLinks: ImmutableArray<FixAdvisoryLink>.Empty,
|
||||
Provenance: provenance,
|
||||
EmittedAt: emittedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if any fix release is available.
|
||||
/// </summary>
|
||||
public bool HasFixAvailable => Status == FixAvailabilityStatus.Available && !Releases.IsDefaultOrEmpty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest fix release if available.
|
||||
/// </summary>
|
||||
public FixRelease? EarliestRelease => Releases.IsDefaultOrEmpty
|
||||
? null
|
||||
: Releases.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue).FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance anchor for fix-availability metadata.
|
||||
/// </summary>
|
||||
public sealed record FixAvailabilityProvenance(
|
||||
string Vendor,
|
||||
string Source,
|
||||
string ObservationHash,
|
||||
DateTimeOffset FetchedAt,
|
||||
string? IngestJobId,
|
||||
string? UpstreamId);
|
||||
|
||||
/// <summary>
|
||||
/// A fix release with version, timestamp, and provenance.
|
||||
/// </summary>
|
||||
public sealed record FixRelease(
|
||||
string? FixedVersion,
|
||||
string? Package,
|
||||
string? Ecosystem,
|
||||
DateTimeOffset? ReleasedAt,
|
||||
FixReleaseType Type,
|
||||
FixAvailabilityProvenance Provenance)
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalizes the ecosystem name to a standard format.
|
||||
/// </summary>
|
||||
public string? NormalizedEcosystem => Ecosystem?.ToLowerInvariant() switch
|
||||
{
|
||||
"npm" or "node" or "nodejs" => "npm",
|
||||
"pypi" or "pip" or "python" => "pypi",
|
||||
"maven" or "java" => "maven",
|
||||
"nuget" or ".net" or "dotnet" => "nuget",
|
||||
"rubygems" or "gem" or "ruby" => "rubygems",
|
||||
"crates.io" or "cargo" or "rust" => "crates.io",
|
||||
"go" or "golang" => "go",
|
||||
"packagist" or "composer" or "php" => "packagist",
|
||||
var e => e
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of fix release.
|
||||
/// </summary>
|
||||
public enum FixReleaseType
|
||||
{
|
||||
/// <summary>Unknown release type.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Patch release addressing the vulnerability.</summary>
|
||||
Patch,
|
||||
|
||||
/// <summary>Minor version upgrade with fix.</summary>
|
||||
MinorUpgrade,
|
||||
|
||||
/// <summary>Major version upgrade with fix.</summary>
|
||||
MajorUpgrade,
|
||||
|
||||
/// <summary>Backported fix to older version line.</summary>
|
||||
Backport,
|
||||
|
||||
/// <summary>Vendor-specific hotfix.</summary>
|
||||
Hotfix
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory link providing fix guidance.
|
||||
/// </summary>
|
||||
public sealed record FixAdvisoryLink(
|
||||
string Url,
|
||||
string? Title,
|
||||
FixAdvisoryLinkType Type,
|
||||
DateTimeOffset? PublishedAt,
|
||||
FixAvailabilityProvenance Provenance);
|
||||
|
||||
/// <summary>
|
||||
/// Type of advisory link.
|
||||
/// </summary>
|
||||
public enum FixAdvisoryLinkType
|
||||
{
|
||||
/// <summary>Unknown link type.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Vendor security advisory.</summary>
|
||||
VendorAdvisory,
|
||||
|
||||
/// <summary>Upstream project advisory.</summary>
|
||||
UpstreamAdvisory,
|
||||
|
||||
/// <summary>Distribution security notice (e.g., RHSA, DSA).</summary>
|
||||
DistributionNotice,
|
||||
|
||||
/// <summary>Patch URL.</summary>
|
||||
PatchUrl,
|
||||
|
||||
/// <summary>Release notes.</summary>
|
||||
ReleaseNotes,
|
||||
|
||||
/// <summary>Commit reference.</summary>
|
||||
Commit,
|
||||
|
||||
/// <summary>Mitigation/workaround guidance.</summary>
|
||||
Mitigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall fix availability status.
|
||||
/// </summary>
|
||||
public enum FixAvailabilityStatus
|
||||
{
|
||||
/// <summary>Fix status unknown.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Fix is available.</summary>
|
||||
Available,
|
||||
|
||||
/// <summary>No fix available yet.</summary>
|
||||
NotAvailable,
|
||||
|
||||
/// <summary>Will not be fixed (end of life, wontfix, etc.).</summary>
|
||||
WillNotFix,
|
||||
|
||||
/// <summary>Fix is in progress.</summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>Deferred - fix planned for future release.</summary>
|
||||
Deferred,
|
||||
|
||||
/// <summary>Not affected - no fix needed.</summary>
|
||||
NotAffected
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Emitter interface for advisory field change notifications.
|
||||
/// Per CONCELIER-RISK-69-001, emits notifications on upstream advisory field changes
|
||||
/// (e.g., fix availability) with observation IDs and provenance; no severity inference.
|
||||
/// </summary>
|
||||
public interface IAdvisoryFieldChangeEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects and emits field change notifications for an observation.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="observationId">Observation identifier.</param>
|
||||
/// <param name="previousSignal">Previous risk signal state (null if new observation).</param>
|
||||
/// <param name="currentSignal">Current risk signal state.</param>
|
||||
/// <param name="linksetId">Optional linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Emitted notification, or null if no changes detected.</returns>
|
||||
Task<AdvisoryFieldChangeNotification?> EmitChangesAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
VendorRiskSignal? previousSignal,
|
||||
VendorRiskSignal currentSignal,
|
||||
string? linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Detects and emits field change notifications for all observations in a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="previousSignals">Previous risk signal states.</param>
|
||||
/// <param name="currentSignals">Current risk signal states.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of emitted notifications.</returns>
|
||||
Task<IReadOnlyList<AdvisoryFieldChangeNotification>> EmitLinksetChangesAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
IReadOnlyList<VendorRiskSignal> previousSignals,
|
||||
IReadOnlyList<VendorRiskSignal> currentSignals,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publisher interface for advisory field change notifications.
|
||||
/// </summary>
|
||||
public interface IAdvisoryFieldChangeNotificationPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes a field change notification to the notification system.
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification to publish.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task PublishAsync(
|
||||
AdvisoryFieldChangeNotification notification,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Emitter interface for structured fix-availability metadata.
|
||||
/// Per CONCELIER-RISK-66-002, emits release version, advisory link, and evidence timestamp
|
||||
/// per observation/linkset without guessing exploitability.
|
||||
/// </summary>
|
||||
public interface IFixAvailabilityEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits fix-availability metadata for a specific observation.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="observationId">Observation identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Emitted fix-availability metadata.</returns>
|
||||
Task<FixAvailabilityMetadata?> EmitByObservationAsync(
|
||||
string tenantId,
|
||||
string observationId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Emits fix-availability metadata for all observations in a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of emitted fix-availability metadata from all linked observations.</returns>
|
||||
Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Emits fix-availability metadata for all observations of an advisory.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="advisoryId">Advisory identifier (e.g., CVE-2024-1234).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Collection of emitted fix-availability metadata.</returns>
|
||||
Task<IReadOnlyList<FixAvailabilityMetadata>> EmitByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated fix-availability view combining metadata from multiple observations.
|
||||
/// </summary>
|
||||
public sealed record AggregatedFixAvailabilityView(
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string? LinksetId,
|
||||
IReadOnlyList<FixAvailabilityMetadata> ObservationMetadata)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the overall fix availability status across all observations.
|
||||
/// Returns the most favorable status (Available > InProgress > NotAvailable > Unknown).
|
||||
/// </summary>
|
||||
public FixAvailabilityStatus OverallStatus
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.Available))
|
||||
return FixAvailabilityStatus.Available;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.InProgress))
|
||||
return FixAvailabilityStatus.InProgress;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.Deferred))
|
||||
return FixAvailabilityStatus.Deferred;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.NotAffected))
|
||||
return FixAvailabilityStatus.NotAffected;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.NotAvailable))
|
||||
return FixAvailabilityStatus.NotAvailable;
|
||||
|
||||
if (ObservationMetadata.Any(m => m.Status == FixAvailabilityStatus.WillNotFix))
|
||||
return FixAvailabilityStatus.WillNotFix;
|
||||
|
||||
return FixAvailabilityStatus.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if any observation reports a fix available.
|
||||
/// </summary>
|
||||
public bool HasFixAvailable => OverallStatus == FixAvailabilityStatus.Available;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique fix releases across observations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FixRelease> AllReleases =>
|
||||
ObservationMetadata
|
||||
.SelectMany(m => m.Releases)
|
||||
.OrderBy(r => r.ReleasedAt ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(r => r.FixedVersion, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique advisory links across observations.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FixAdvisoryLink> AllAdvisoryLinks =>
|
||||
ObservationMetadata
|
||||
.SelectMany(m => m.AdvisoryLinks)
|
||||
.DistinctBy(l => l.Url)
|
||||
.OrderBy(l => l.PublishedAt ?? DateTimeOffset.MaxValue)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets vendors that contributed fix information.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ContributingVendors =>
|
||||
ObservationMetadata
|
||||
.Select(m => m.Provenance.Vendor)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest available fix release across all observations.
|
||||
/// </summary>
|
||||
public FixRelease? EarliestRelease => AllReleases.FirstOrDefault();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Publisher interface for per-source coverage and conflict metrics.
|
||||
/// Per CONCELIER-RISK-67-001, publishes counts and disagreements so explainers
|
||||
/// cite which upstream statements exist; no weighting applied.
|
||||
/// </summary>
|
||||
public interface ISourceCoverageMetricsPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes and publishes coverage metrics for all observations of an advisory.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="advisoryId">Advisory identifier (e.g., CVE-2024-1234).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Computed coverage metrics.</returns>
|
||||
Task<SourceCoverageMetrics> PublishByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Computes and publishes coverage metrics for all observations in a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Computed coverage metrics.</returns>
|
||||
Task<SourceCoverageMetrics> PublishByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest published coverage metrics for an advisory.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="advisoryId">Advisory identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Latest coverage metrics, or null if not computed.</returns>
|
||||
Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest published coverage metrics for a linkset.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="linksetId">Linkset identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Latest coverage metrics, or null if not computed.</returns>
|
||||
Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for coverage metrics persistence.
|
||||
/// </summary>
|
||||
public interface ISourceCoverageMetricsStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores coverage metrics.
|
||||
/// </summary>
|
||||
Task StoreAsync(
|
||||
SourceCoverageMetrics metrics,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves coverage metrics by advisory.
|
||||
/// </summary>
|
||||
Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves coverage metrics by linkset.
|
||||
/// </summary>
|
||||
Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IAdvisoryFieldChangeNotificationPublisher"/> for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAdvisoryFieldChangeNotificationPublisher : IAdvisoryFieldChangeNotificationPublisher
|
||||
{
|
||||
private readonly ConcurrentQueue<AdvisoryFieldChangeNotification> _notifications = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync(AdvisoryFieldChangeNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
_notifications.Enqueue(notification);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all published notifications (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetAllNotifications()
|
||||
{
|
||||
return _notifications.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets notifications for a specific advisory (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByAdvisory(string advisoryId)
|
||||
{
|
||||
return _notifications
|
||||
.Where(n => string.Equals(n.AdvisoryId, advisoryId, System.StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets notifications for a specific tenant (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByTenant(string tenantId)
|
||||
{
|
||||
return _notifications
|
||||
.Where(n => string.Equals(n.TenantId, tenantId, System.StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets notifications by change type (for testing).
|
||||
/// </summary>
|
||||
public IReadOnlyList<AdvisoryFieldChangeNotification> GetNotificationsByChangeType(AdvisoryFieldChangeType changeType)
|
||||
{
|
||||
return _notifications
|
||||
.Where(n => n.ChangeType == changeType)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all notifications (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
while (_notifications.TryDequeue(out _))
|
||||
{
|
||||
// Clear the queue
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of notifications (for testing).
|
||||
/// </summary>
|
||||
public int Count => _notifications.Count;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISourceCoverageMetricsStore"/> for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemorySourceCoverageMetricsStore : ISourceCoverageMetricsStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SourceCoverageMetrics> _byAdvisory = new();
|
||||
private readonly ConcurrentDictionary<string, SourceCoverageMetrics> _byLinkset = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StoreAsync(SourceCoverageMetrics metrics, CancellationToken cancellationToken)
|
||||
{
|
||||
var advisoryKey = BuildAdvisoryKey(metrics.TenantId, metrics.AdvisoryId);
|
||||
_byAdvisory[advisoryKey] = metrics;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metrics.LinksetId))
|
||||
{
|
||||
var linksetKey = BuildLinksetKey(metrics.TenantId, metrics.LinksetId);
|
||||
_byLinkset[linksetKey] = metrics;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var key = BuildAdvisoryKey(tenantId, advisoryId);
|
||||
_byAdvisory.TryGetValue(key, out var metrics);
|
||||
return Task.FromResult(metrics);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var key = BuildLinksetKey(tenantId, linksetId);
|
||||
_byLinkset.TryGetValue(key, out var metrics);
|
||||
return Task.FromResult(metrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all stored metrics (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_byAdvisory.Clear();
|
||||
_byLinkset.Clear();
|
||||
}
|
||||
|
||||
private static string BuildAdvisoryKey(string tenantId, string advisoryId)
|
||||
=> $"{tenantId}:{advisoryId}";
|
||||
|
||||
private static string BuildLinksetKey(string tenantId, string linksetId)
|
||||
=> $"{tenantId}:linkset:{linksetId}";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for risk-related services.
|
||||
/// </summary>
|
||||
public static class RiskServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds risk signal and fix-availability services to the service collection.
|
||||
/// Per CONCELIER-RISK-66-002, CONCELIER-RISK-67-001, and CONCELIER-RISK-69-001.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddConcelierRiskServices(this IServiceCollection services)
|
||||
{
|
||||
// Register fix-availability emitter (CONCELIER-RISK-66-002)
|
||||
services.TryAddSingleton<IFixAvailabilityEmitter, FixAvailabilityEmitter>();
|
||||
|
||||
// Register coverage metrics services (CONCELIER-RISK-67-001)
|
||||
services.TryAddSingleton<ISourceCoverageMetricsStore, InMemorySourceCoverageMetricsStore>();
|
||||
services.TryAddSingleton<ISourceCoverageMetricsPublisher, SourceCoverageMetricsPublisher>();
|
||||
|
||||
// Register field change notification services (CONCELIER-RISK-69-001)
|
||||
services.TryAddSingleton<IAdvisoryFieldChangeNotificationPublisher, InMemoryAdvisoryFieldChangeNotificationPublisher>();
|
||||
services.TryAddSingleton<IAdvisoryFieldChangeEmitter, AdvisoryFieldChangeEmitter>();
|
||||
|
||||
// TimeProvider is typically registered elsewhere, but ensure it exists
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IVendorRiskSignalProvider"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProvider">The provider implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddVendorRiskSignalProvider<TProvider>(this IServiceCollection services)
|
||||
where TProvider : class, IVendorRiskSignalProvider
|
||||
{
|
||||
services.TryAddSingleton<IVendorRiskSignalProvider, TProvider>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IFixAvailabilityEmitter"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEmitter">The emitter implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFixAvailabilityEmitter<TEmitter>(this IServiceCollection services)
|
||||
where TEmitter : class, IFixAvailabilityEmitter
|
||||
{
|
||||
services.AddSingleton<IFixAvailabilityEmitter, TEmitter>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="ISourceCoverageMetricsStore"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TStore">The store implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourceCoverageMetricsStore<TStore>(this IServiceCollection services)
|
||||
where TStore : class, ISourceCoverageMetricsStore
|
||||
{
|
||||
services.AddSingleton<ISourceCoverageMetricsStore, TStore>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="ISourceCoverageMetricsPublisher"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPublisher">The publisher implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSourceCoverageMetricsPublisher<TPublisher>(this IServiceCollection services)
|
||||
where TPublisher : class, ISourceCoverageMetricsPublisher
|
||||
{
|
||||
services.AddSingleton<ISourceCoverageMetricsPublisher, TPublisher>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IAdvisoryFieldChangeNotificationPublisher"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TPublisher">The publisher implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAdvisoryFieldChangeNotificationPublisher<TPublisher>(this IServiceCollection services)
|
||||
where TPublisher : class, IAdvisoryFieldChangeNotificationPublisher
|
||||
{
|
||||
services.AddSingleton<IAdvisoryFieldChangeNotificationPublisher, TPublisher>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a custom implementation of <see cref="IAdvisoryFieldChangeEmitter"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TEmitter">The emitter implementation type.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAdvisoryFieldChangeEmitter<TEmitter>(this IServiceCollection services)
|
||||
where TEmitter : class, IAdvisoryFieldChangeEmitter
|
||||
{
|
||||
services.AddSingleton<IAdvisoryFieldChangeEmitter, TEmitter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Per-source coverage and conflict metrics for advisory observations.
|
||||
/// Per CONCELIER-RISK-67-001, publishes counts and disagreements so explainers
|
||||
/// cite which upstream statements exist; no weighting applied.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This model is fact-only: no inference, weighting, or prioritization.
|
||||
/// All data traces back to specific vendor observations with provenance.
|
||||
/// </remarks>
|
||||
public sealed record SourceCoverageMetrics(
|
||||
string TenantId,
|
||||
string AdvisoryId,
|
||||
string? LinksetId,
|
||||
ImmutableArray<SourceContribution> Sources,
|
||||
SourceAgreementSummary Agreement,
|
||||
ImmutableArray<SourceConflict> Conflicts,
|
||||
DateTimeOffset ComputedAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of contributing sources.
|
||||
/// </summary>
|
||||
public int SourceCount => Sources.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Total number of observations across all sources.
|
||||
/// </summary>
|
||||
public int TotalObservations => Sources.Sum(s => s.ObservationCount);
|
||||
|
||||
/// <summary>
|
||||
/// Total number of conflicts detected.
|
||||
/// </summary>
|
||||
public int ConflictCount => Conflicts.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if all sources agree (no conflicts).
|
||||
/// </summary>
|
||||
public bool AllSourcesAgree => Conflicts.IsDefaultOrEmpty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates empty coverage metrics when no sources are available.
|
||||
/// </summary>
|
||||
public static SourceCoverageMetrics Empty(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
string? linksetId,
|
||||
DateTimeOffset computedAt)
|
||||
{
|
||||
return new SourceCoverageMetrics(
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: advisoryId,
|
||||
LinksetId: linksetId,
|
||||
Sources: ImmutableArray<SourceContribution>.Empty,
|
||||
Agreement: SourceAgreementSummary.Empty,
|
||||
Conflicts: ImmutableArray<SourceConflict>.Empty,
|
||||
ComputedAt: computedAt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contribution from a single source/vendor.
|
||||
/// </summary>
|
||||
public sealed record SourceContribution(
|
||||
string SourceId,
|
||||
string Vendor,
|
||||
int ObservationCount,
|
||||
ImmutableArray<string> ObservationIds,
|
||||
SourceCoverageDetail Coverage,
|
||||
DateTimeOffset LatestObservationAt)
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates if this source has full coverage (CVSS, fix info, affected data).
|
||||
/// </summary>
|
||||
public bool HasFullCoverage =>
|
||||
Coverage.HasCvssData &&
|
||||
Coverage.HasFixData &&
|
||||
Coverage.HasAffectedData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detail of what data a source provides.
|
||||
/// </summary>
|
||||
public sealed record SourceCoverageDetail(
|
||||
bool HasCvssData,
|
||||
bool HasKevData,
|
||||
bool HasFixData,
|
||||
bool HasAffectedData,
|
||||
bool HasReferenceData,
|
||||
ImmutableArray<string> CvssVersions,
|
||||
ImmutableArray<string> AffectedEcosystems);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of source agreement/disagreement.
|
||||
/// </summary>
|
||||
public sealed record SourceAgreementSummary(
|
||||
int TotalFields,
|
||||
int AgreeingFields,
|
||||
int DisagreeingFields,
|
||||
ImmutableArray<string> AgreedFieldNames,
|
||||
ImmutableArray<string> DisagreedFieldNames)
|
||||
{
|
||||
/// <summary>
|
||||
/// Agreement ratio (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public double AgreementRatio => TotalFields > 0
|
||||
? (double)AgreeingFields / TotalFields
|
||||
: 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates high agreement (>= 90%).
|
||||
/// </summary>
|
||||
public bool HighAgreement => AgreementRatio >= 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Empty agreement summary when no fields to compare.
|
||||
/// </summary>
|
||||
public static SourceAgreementSummary Empty => new(
|
||||
TotalFields: 0,
|
||||
AgreeingFields: 0,
|
||||
DisagreeingFields: 0,
|
||||
AgreedFieldNames: ImmutableArray<string>.Empty,
|
||||
DisagreedFieldNames: ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A specific conflict between sources.
|
||||
/// </summary>
|
||||
public sealed record SourceConflict(
|
||||
string Field,
|
||||
ConflictType Type,
|
||||
ImmutableArray<SourceConflictValue> Values,
|
||||
string? Resolution)
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of sources involved in this conflict.
|
||||
/// </summary>
|
||||
public int SourceCount => Values.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of conflict between sources.
|
||||
/// </summary>
|
||||
public enum ConflictType
|
||||
{
|
||||
/// <summary>Sources report different values for the same field.</summary>
|
||||
ValueMismatch,
|
||||
|
||||
/// <summary>One source has data, another does not.</summary>
|
||||
MissingData,
|
||||
|
||||
/// <summary>Severity scores differ significantly.</summary>
|
||||
SeverityDivergence,
|
||||
|
||||
/// <summary>Fix availability status differs.</summary>
|
||||
FixStatusDivergence,
|
||||
|
||||
/// <summary>Affected version ranges conflict.</summary>
|
||||
AffectedRangeConflict,
|
||||
|
||||
/// <summary>KEV status differs between sources.</summary>
|
||||
KevStatusConflict
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A source's value in a conflict.
|
||||
/// </summary>
|
||||
public sealed record SourceConflictValue(
|
||||
string SourceId,
|
||||
string Vendor,
|
||||
string? Value,
|
||||
string? ObservationId,
|
||||
DateTimeOffset? ObservedAt);
|
||||
@@ -0,0 +1,414 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Risk;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISourceCoverageMetricsPublisher"/>.
|
||||
/// Per CONCELIER-RISK-67-001, publishes per-source coverage/conflict metrics
|
||||
/// so explainers cite which upstream statements exist; no weighting applied.
|
||||
/// </summary>
|
||||
public sealed class SourceCoverageMetricsPublisher : ISourceCoverageMetricsPublisher
|
||||
{
|
||||
private readonly IVendorRiskSignalProvider _riskSignalProvider;
|
||||
private readonly ISourceCoverageMetricsStore _store;
|
||||
private readonly ILogger<SourceCoverageMetricsPublisher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SourceCoverageMetricsPublisher(
|
||||
IVendorRiskSignalProvider riskSignalProvider,
|
||||
ISourceCoverageMetricsStore store,
|
||||
ILogger<SourceCoverageMetricsPublisher> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_riskSignalProvider = riskSignalProvider ?? throw new ArgumentNullException(nameof(riskSignalProvider));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceCoverageMetrics> PublishByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryId);
|
||||
|
||||
var signals = await _riskSignalProvider.GetByAdvisoryAsync(
|
||||
tenantId, advisoryId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var metrics = ComputeMetrics(tenantId, advisoryId, linksetId: null, signals);
|
||||
|
||||
await _store.StoreAsync(metrics, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published coverage metrics for advisory {AdvisoryId}: {SourceCount} sources, {ConflictCount} conflicts",
|
||||
advisoryId, metrics.SourceCount, metrics.ConflictCount);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SourceCoverageMetrics> PublishByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(linksetId);
|
||||
|
||||
var signals = await _riskSignalProvider.GetByLinksetAsync(
|
||||
tenantId, linksetId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var advisoryId = signals.FirstOrDefault()?.AdvisoryId ?? "unknown";
|
||||
var metrics = ComputeMetrics(tenantId, advisoryId, linksetId, signals);
|
||||
|
||||
await _store.StoreAsync(metrics, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published coverage metrics for linkset {LinksetId}: {SourceCount} sources, {ConflictCount} conflicts",
|
||||
linksetId, metrics.SourceCount, metrics.ConflictCount);
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByAdvisoryAsync(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _store.GetByAdvisoryAsync(tenantId, advisoryId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SourceCoverageMetrics?> GetByLinksetAsync(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _store.GetByLinksetAsync(tenantId, linksetId, cancellationToken);
|
||||
}
|
||||
|
||||
private SourceCoverageMetrics ComputeMetrics(
|
||||
string tenantId,
|
||||
string advisoryId,
|
||||
string? linksetId,
|
||||
IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (signals.Count == 0)
|
||||
{
|
||||
return SourceCoverageMetrics.Empty(tenantId, advisoryId, linksetId, now);
|
||||
}
|
||||
|
||||
var sources = ComputeSourceContributions(signals);
|
||||
var agreement = ComputeAgreementSummary(signals);
|
||||
var conflicts = DetectConflicts(signals);
|
||||
|
||||
return new SourceCoverageMetrics(
|
||||
TenantId: tenantId,
|
||||
AdvisoryId: advisoryId,
|
||||
LinksetId: linksetId,
|
||||
Sources: sources,
|
||||
Agreement: agreement,
|
||||
Conflicts: conflicts,
|
||||
ComputedAt: now);
|
||||
}
|
||||
|
||||
private static ImmutableArray<SourceContribution> ComputeSourceContributions(
|
||||
IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var bySource = signals
|
||||
.GroupBy(s => (s.Provenance.Source, s.Provenance.Vendor))
|
||||
.OrderBy(g => g.Key.Source, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(g => g.Key.Vendor, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var contributions = ImmutableArray.CreateBuilder<SourceContribution>();
|
||||
|
||||
foreach (var group in bySource)
|
||||
{
|
||||
var sourceSignals = group.ToList();
|
||||
var observationIds = sourceSignals
|
||||
.Select(s => s.ObservationId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var coverage = ComputeCoverageDetail(sourceSignals);
|
||||
var latestAt = sourceSignals.Max(s => s.ExtractedAt);
|
||||
|
||||
contributions.Add(new SourceContribution(
|
||||
SourceId: group.Key.Source,
|
||||
Vendor: group.Key.Vendor,
|
||||
ObservationCount: observationIds.Length,
|
||||
ObservationIds: observationIds,
|
||||
Coverage: coverage,
|
||||
LatestObservationAt: latestAt));
|
||||
}
|
||||
|
||||
return contributions.ToImmutable();
|
||||
}
|
||||
|
||||
private static SourceCoverageDetail ComputeCoverageDetail(List<VendorRiskSignal> signals)
|
||||
{
|
||||
var hasCvss = signals.Any(s => !s.CvssScores.IsDefaultOrEmpty);
|
||||
var hasKev = signals.Any(s => s.KevStatus is not null);
|
||||
var hasFix = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty);
|
||||
var hasAffected = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty &&
|
||||
s.FixAvailability.Any(f => !string.IsNullOrWhiteSpace(f.Package)));
|
||||
var hasReferences = signals.Any(s => !s.FixAvailability.IsDefaultOrEmpty &&
|
||||
s.FixAvailability.Any(f => !string.IsNullOrWhiteSpace(f.AdvisoryUrl)));
|
||||
|
||||
var cvssVersions = signals
|
||||
.SelectMany(s => s.CvssScores)
|
||||
.Select(c => c.NormalizedSystem)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(v => v, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var ecosystems = signals
|
||||
.SelectMany(s => s.FixAvailability)
|
||||
.Where(f => !string.IsNullOrWhiteSpace(f.Ecosystem))
|
||||
.Select(f => f.Ecosystem!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceCoverageDetail(
|
||||
HasCvssData: hasCvss,
|
||||
HasKevData: hasKev,
|
||||
HasFixData: hasFix,
|
||||
HasAffectedData: hasAffected,
|
||||
HasReferenceData: hasReferences,
|
||||
CvssVersions: cvssVersions,
|
||||
AffectedEcosystems: ecosystems);
|
||||
}
|
||||
|
||||
private static SourceAgreementSummary ComputeAgreementSummary(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
if (signals.Count <= 1)
|
||||
{
|
||||
return SourceAgreementSummary.Empty;
|
||||
}
|
||||
|
||||
var agreedFields = new List<string>();
|
||||
var disagreedFields = new List<string>();
|
||||
|
||||
// Check CVSS severity agreement
|
||||
var severities = signals
|
||||
.Where(s => s.HighestCvssScore is not null)
|
||||
.Select(s => s.HighestCvssScore!.EffectiveSeverity)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (severities.Count > 0)
|
||||
{
|
||||
if (severities.Count == 1)
|
||||
{
|
||||
agreedFields.Add("severity");
|
||||
}
|
||||
else
|
||||
{
|
||||
disagreedFields.Add("severity");
|
||||
}
|
||||
}
|
||||
|
||||
// Check KEV status agreement
|
||||
var kevStatuses = signals
|
||||
.Where(s => s.KevStatus is not null)
|
||||
.Select(s => s.KevStatus!.InKev)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (kevStatuses.Count > 0)
|
||||
{
|
||||
if (kevStatuses.Count == 1)
|
||||
{
|
||||
agreedFields.Add("kev_status");
|
||||
}
|
||||
else
|
||||
{
|
||||
disagreedFields.Add("kev_status");
|
||||
}
|
||||
}
|
||||
|
||||
// Check fix availability agreement
|
||||
var fixStatuses = signals
|
||||
.Select(s => s.HasFixAvailable)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (fixStatuses.Count == 1)
|
||||
{
|
||||
agreedFields.Add("fix_availability");
|
||||
}
|
||||
else if (fixStatuses.Count > 1)
|
||||
{
|
||||
disagreedFields.Add("fix_availability");
|
||||
}
|
||||
|
||||
var totalFields = agreedFields.Count + disagreedFields.Count;
|
||||
|
||||
return new SourceAgreementSummary(
|
||||
TotalFields: totalFields,
|
||||
AgreeingFields: agreedFields.Count,
|
||||
DisagreeingFields: disagreedFields.Count,
|
||||
AgreedFieldNames: agreedFields.ToImmutableArray(),
|
||||
DisagreedFieldNames: disagreedFields.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static ImmutableArray<SourceConflict> DetectConflicts(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
if (signals.Count <= 1)
|
||||
{
|
||||
return ImmutableArray<SourceConflict>.Empty;
|
||||
}
|
||||
|
||||
var conflicts = ImmutableArray.CreateBuilder<SourceConflict>();
|
||||
|
||||
// Detect severity divergence
|
||||
var severityConflict = DetectSeverityConflict(signals);
|
||||
if (severityConflict is not null)
|
||||
{
|
||||
conflicts.Add(severityConflict);
|
||||
}
|
||||
|
||||
// Detect KEV status conflict
|
||||
var kevConflict = DetectKevConflict(signals);
|
||||
if (kevConflict is not null)
|
||||
{
|
||||
conflicts.Add(kevConflict);
|
||||
}
|
||||
|
||||
// Detect fix status divergence
|
||||
var fixConflict = DetectFixStatusConflict(signals);
|
||||
if (fixConflict is not null)
|
||||
{
|
||||
conflicts.Add(fixConflict);
|
||||
}
|
||||
|
||||
return conflicts.ToImmutable();
|
||||
}
|
||||
|
||||
private static SourceConflict? DetectSeverityConflict(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var signalsWithSeverity = signals
|
||||
.Where(s => s.HighestCvssScore is not null)
|
||||
.ToList();
|
||||
|
||||
if (signalsWithSeverity.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var severities = signalsWithSeverity
|
||||
.Select(s => s.HighestCvssScore!.EffectiveSeverity)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (severities.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = signalsWithSeverity
|
||||
.Select(s => new SourceConflictValue(
|
||||
SourceId: s.Provenance.Source,
|
||||
Vendor: s.Provenance.Vendor,
|
||||
Value: s.HighestCvssScore!.EffectiveSeverity,
|
||||
ObservationId: s.ObservationId,
|
||||
ObservedAt: s.ExtractedAt))
|
||||
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceConflict(
|
||||
Field: "severity",
|
||||
Type: ConflictType.SeverityDivergence,
|
||||
Values: values,
|
||||
Resolution: null);
|
||||
}
|
||||
|
||||
private static SourceConflict? DetectKevConflict(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var signalsWithKev = signals
|
||||
.Where(s => s.KevStatus is not null)
|
||||
.ToList();
|
||||
|
||||
if (signalsWithKev.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var kevStatuses = signalsWithKev
|
||||
.Select(s => s.KevStatus!.InKev)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (kevStatuses.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = signalsWithKev
|
||||
.Select(s => new SourceConflictValue(
|
||||
SourceId: s.Provenance.Source,
|
||||
Vendor: s.Provenance.Vendor,
|
||||
Value: s.KevStatus!.InKev ? "in_kev" : "not_in_kev",
|
||||
ObservationId: s.ObservationId,
|
||||
ObservedAt: s.ExtractedAt))
|
||||
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceConflict(
|
||||
Field: "kev_status",
|
||||
Type: ConflictType.KevStatusConflict,
|
||||
Values: values,
|
||||
Resolution: null);
|
||||
}
|
||||
|
||||
private static SourceConflict? DetectFixStatusConflict(IReadOnlyList<VendorRiskSignal> signals)
|
||||
{
|
||||
var fixStatuses = signals
|
||||
.Select(s => (Signal: s, HasFix: s.HasFixAvailable))
|
||||
.ToList();
|
||||
|
||||
var distinctStatuses = fixStatuses
|
||||
.Select(x => x.HasFix)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (distinctStatuses.Count <= 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var values = fixStatuses
|
||||
.Select(x => new SourceConflictValue(
|
||||
SourceId: x.Signal.Provenance.Source,
|
||||
Vendor: x.Signal.Provenance.Vendor,
|
||||
Value: x.HasFix ? "fix_available" : "no_fix",
|
||||
ObservationId: x.Signal.ObservationId,
|
||||
ObservedAt: x.Signal.ExtractedAt))
|
||||
.OrderBy(v => v.SourceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(v => v.Vendor, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SourceConflict(
|
||||
Field: "fix_availability",
|
||||
Type: ConflictType.FixStatusDivergence,
|
||||
Values: values,
|
||||
Resolution: null);
|
||||
}
|
||||
}
|
||||
93
src/Policy/StellaOps.Policy.Scoring/AGENTS.md
Normal file
93
src/Policy/StellaOps.Policy.Scoring/AGENTS.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# AGENTS.md - StellaOps.Policy.Scoring
|
||||
|
||||
## Module Summary
|
||||
The CVSS v4.0 Scoring module provides deterministic score computation with full audit trail via receipts. It implements the FIRST CVSS v4.0 specification for vulnerability scoring with policy-driven customization and evidence linkage.
|
||||
|
||||
## Working Directory
|
||||
`src/Policy/StellaOps.Policy.Scoring`
|
||||
|
||||
## Required Reading
|
||||
Before implementing in this module, read:
|
||||
1. `docs/README.md`
|
||||
2. `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
3. `docs/modules/policy/architecture.md`
|
||||
4. FIRST CVSS v4.0 Specification: https://www.first.org/cvss/v4-0/specification-document
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
### This Module Owns
|
||||
- CVSS v4.0 data models (`CvssMetrics.cs`, `CvssScoreReceipt.cs`, `CvssPolicy.cs`)
|
||||
- CVSS v4.0 scoring engine (`Engine/CvssV4Engine.cs`)
|
||||
- Receipt generation and management (`Receipts/ReceiptBuilder.cs`)
|
||||
- Policy loading and validation (`Policies/CvssPolicyLoader.cs`)
|
||||
- JSON schemas for receipts and policies (`Schemas/`)
|
||||
|
||||
### This Module Does NOT Own
|
||||
- Attestation/DSSE envelope creation (use `StellaOps.Attestor.Envelope`)
|
||||
- Vulnerability advisory ingestion (use `StellaOps.Concelier.Core`)
|
||||
- VEX statement handling (use `StellaOps.Excititor.Core`)
|
||||
- General policy evaluation (use `StellaOps.Policy`)
|
||||
|
||||
## Determinism Requirements
|
||||
|
||||
### Input Reproducibility
|
||||
- All score computations must be deterministic: same inputs → same outputs
|
||||
- Receipt `inputHash` field captures SHA-256 of normalized inputs
|
||||
- Use stable JSON serialization with ordered keys for hashing
|
||||
|
||||
### Score Computation
|
||||
- Follow FIRST CVSS v4.0 math exactly (MacroVector lookup tables, EQ formulas)
|
||||
- Use "Round Up" rounding per FIRST spec: `ceil(score * 10) / 10`
|
||||
- Never introduce floating-point non-determinism
|
||||
|
||||
### Timestamp Handling
|
||||
- All timestamps must be UTC in ISO-8601 format
|
||||
- Use `DateTimeOffset.UtcNow` for creation times
|
||||
- Include timestamp in input hash for temporal reproducibility
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
- Test all CVSS v4.0 metric combinations using FIRST sample vectors
|
||||
- Test edge cases: missing optional metrics, all-None impacts, boundary scores
|
||||
- Test determinism: multiple computations of same input must match
|
||||
|
||||
### Integration Tests
|
||||
- Test policy loading and validation
|
||||
- Test receipt persistence and retrieval
|
||||
- Test amendment workflow with history tracking
|
||||
|
||||
## API Contract
|
||||
|
||||
### Score Computation
|
||||
```csharp
|
||||
// Engine interface
|
||||
public interface ICvssV4Engine
|
||||
{
|
||||
CvssScores ComputeScores(CvssBaseMetrics baseMetrics, CvssThreatMetrics? threatMetrics = null, CvssEnvironmentalMetrics? envMetrics = null);
|
||||
string BuildVectorString(CvssBaseMetrics baseMetrics, CvssThreatMetrics? threatMetrics = null, CvssEnvironmentalMetrics? envMetrics = null, CvssSupplementalMetrics? suppMetrics = null);
|
||||
(CvssBaseMetrics Base, CvssThreatMetrics? Threat, CvssEnvironmentalMetrics? Env, CvssSupplementalMetrics? Supp) ParseVector(string vectorString);
|
||||
}
|
||||
```
|
||||
|
||||
### Receipt Builder
|
||||
```csharp
|
||||
// Receipt builder interface
|
||||
public interface IReceiptBuilder
|
||||
{
|
||||
Task<CvssScoreReceipt> CreateReceiptAsync(CreateReceiptRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CvssScoreReceipt> AmendReceiptAsync(string receiptId, AmendReceiptRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Versioning
|
||||
- Policy schema: `cvss-policy-schema@1.json`
|
||||
- Receipt schema: `cvss-receipt-schema@1.json`
|
||||
- Version increment required for breaking changes
|
||||
- Maintain backward compatibility where possible
|
||||
|
||||
## Security Considerations
|
||||
- Validate all policy inputs against JSON schema
|
||||
- Sanitize vulnerability IDs to prevent injection
|
||||
- Sign receipts via DSSE when attestation is required
|
||||
- Tenant isolation: receipts are tenant-scoped
|
||||
354
src/Policy/StellaOps.Policy.Scoring/CvssMetrics.cs
Normal file
354
src/Policy/StellaOps.Policy.Scoring/CvssMetrics.cs
Normal file
@@ -0,0 +1,354 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Base metric group - Exploitability and impact metrics.
|
||||
/// Per FIRST CVSS v4.0 Specification Document.
|
||||
/// </summary>
|
||||
public sealed record CvssBaseMetrics
|
||||
{
|
||||
/// <summary>Attack Vector (AV) - Mandatory.</summary>
|
||||
[JsonPropertyName("av")]
|
||||
public required AttackVector AttackVector { get; init; }
|
||||
|
||||
/// <summary>Attack Complexity (AC) - Mandatory.</summary>
|
||||
[JsonPropertyName("ac")]
|
||||
public required AttackComplexity AttackComplexity { get; init; }
|
||||
|
||||
/// <summary>Attack Requirements (AT) - Mandatory.</summary>
|
||||
[JsonPropertyName("at")]
|
||||
public required AttackRequirements AttackRequirements { get; init; }
|
||||
|
||||
/// <summary>Privileges Required (PR) - Mandatory.</summary>
|
||||
[JsonPropertyName("pr")]
|
||||
public required PrivilegesRequired PrivilegesRequired { get; init; }
|
||||
|
||||
/// <summary>User Interaction (UI) - Mandatory.</summary>
|
||||
[JsonPropertyName("ui")]
|
||||
public required UserInteraction UserInteraction { get; init; }
|
||||
|
||||
/// <summary>Vulnerable System Confidentiality (VC) - Mandatory.</summary>
|
||||
[JsonPropertyName("vc")]
|
||||
public required ImpactMetricValue VulnerableSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Vulnerable System Integrity (VI) - Mandatory.</summary>
|
||||
[JsonPropertyName("vi")]
|
||||
public required ImpactMetricValue VulnerableSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Vulnerable System Availability (VA) - Mandatory.</summary>
|
||||
[JsonPropertyName("va")]
|
||||
public required ImpactMetricValue VulnerableSystemAvailability { get; init; }
|
||||
|
||||
/// <summary>Subsequent System Confidentiality (SC) - Mandatory.</summary>
|
||||
[JsonPropertyName("sc")]
|
||||
public required ImpactMetricValue SubsequentSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Subsequent System Integrity (SI) - Mandatory.</summary>
|
||||
[JsonPropertyName("si")]
|
||||
public required ImpactMetricValue SubsequentSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Subsequent System Availability (SA) - Mandatory.</summary>
|
||||
[JsonPropertyName("sa")]
|
||||
public required ImpactMetricValue SubsequentSystemAvailability { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Threat metric group.
|
||||
/// </summary>
|
||||
public sealed record CvssThreatMetrics
|
||||
{
|
||||
/// <summary>Exploit Maturity (E) - Optional, defaults to Not Defined.</summary>
|
||||
[JsonPropertyName("e")]
|
||||
public ExploitMaturity ExploitMaturity { get; init; } = ExploitMaturity.NotDefined;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Environmental metric group - Modified base metrics for specific environments.
|
||||
/// </summary>
|
||||
public sealed record CvssEnvironmentalMetrics
|
||||
{
|
||||
/// <summary>Modified Attack Vector (MAV).</summary>
|
||||
[JsonPropertyName("mav")]
|
||||
public ModifiedAttackVector? ModifiedAttackVector { get; init; }
|
||||
|
||||
/// <summary>Modified Attack Complexity (MAC).</summary>
|
||||
[JsonPropertyName("mac")]
|
||||
public ModifiedAttackComplexity? ModifiedAttackComplexity { get; init; }
|
||||
|
||||
/// <summary>Modified Attack Requirements (MAT).</summary>
|
||||
[JsonPropertyName("mat")]
|
||||
public ModifiedAttackRequirements? ModifiedAttackRequirements { get; init; }
|
||||
|
||||
/// <summary>Modified Privileges Required (MPR).</summary>
|
||||
[JsonPropertyName("mpr")]
|
||||
public ModifiedPrivilegesRequired? ModifiedPrivilegesRequired { get; init; }
|
||||
|
||||
/// <summary>Modified User Interaction (MUI).</summary>
|
||||
[JsonPropertyName("mui")]
|
||||
public ModifiedUserInteraction? ModifiedUserInteraction { get; init; }
|
||||
|
||||
/// <summary>Modified Vulnerable System Confidentiality (MVC).</summary>
|
||||
[JsonPropertyName("mvc")]
|
||||
public ModifiedImpactMetricValue? ModifiedVulnerableSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Modified Vulnerable System Integrity (MVI).</summary>
|
||||
[JsonPropertyName("mvi")]
|
||||
public ModifiedImpactMetricValue? ModifiedVulnerableSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Modified Vulnerable System Availability (MVA).</summary>
|
||||
[JsonPropertyName("mva")]
|
||||
public ModifiedImpactMetricValue? ModifiedVulnerableSystemAvailability { get; init; }
|
||||
|
||||
/// <summary>Modified Subsequent System Confidentiality (MSC).</summary>
|
||||
[JsonPropertyName("msc")]
|
||||
public ModifiedImpactMetricValue? ModifiedSubsequentSystemConfidentiality { get; init; }
|
||||
|
||||
/// <summary>Modified Subsequent System Integrity (MSI).</summary>
|
||||
[JsonPropertyName("msi")]
|
||||
public ModifiedSubsequentImpact? ModifiedSubsequentSystemIntegrity { get; init; }
|
||||
|
||||
/// <summary>Modified Subsequent System Availability (MSA).</summary>
|
||||
[JsonPropertyName("msa")]
|
||||
public ModifiedSubsequentImpact? ModifiedSubsequentSystemAvailability { get; init; }
|
||||
|
||||
/// <summary>Confidentiality Requirement (CR).</summary>
|
||||
[JsonPropertyName("cr")]
|
||||
public SecurityRequirement? ConfidentialityRequirement { get; init; }
|
||||
|
||||
/// <summary>Integrity Requirement (IR).</summary>
|
||||
[JsonPropertyName("ir")]
|
||||
public SecurityRequirement? IntegrityRequirement { get; init; }
|
||||
|
||||
/// <summary>Availability Requirement (AR).</summary>
|
||||
[JsonPropertyName("ar")]
|
||||
public SecurityRequirement? AvailabilityRequirement { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 Supplemental metric group - Additional context metrics that do not affect scoring.
|
||||
/// </summary>
|
||||
public sealed record CvssSupplementalMetrics
|
||||
{
|
||||
/// <summary>Safety (S) - Does the vulnerability affect human safety?</summary>
|
||||
[JsonPropertyName("s")]
|
||||
public Safety? Safety { get; init; }
|
||||
|
||||
/// <summary>Automatable (AU) - Can the vulnerability be exploited automatically?</summary>
|
||||
[JsonPropertyName("au")]
|
||||
public Automatable? Automatable { get; init; }
|
||||
|
||||
/// <summary>Recovery (R) - What is the recovery capability?</summary>
|
||||
[JsonPropertyName("r")]
|
||||
public Recovery? Recovery { get; init; }
|
||||
|
||||
/// <summary>Value Density (V) - Resource density of the vulnerable system.</summary>
|
||||
[JsonPropertyName("v")]
|
||||
public ValueDensity? ValueDensity { get; init; }
|
||||
|
||||
/// <summary>Vulnerability Response Effort (RE) - Effort required to respond.</summary>
|
||||
[JsonPropertyName("re")]
|
||||
public ResponseEffort? VulnerabilityResponseEffort { get; init; }
|
||||
|
||||
/// <summary>Provider Urgency (U) - Urgency as assessed by the provider.</summary>
|
||||
[JsonPropertyName("u")]
|
||||
public ProviderUrgency? ProviderUrgency { get; init; }
|
||||
}
|
||||
|
||||
#region Base Metric Enums
|
||||
|
||||
/// <summary>Attack Vector values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttackVector
|
||||
{
|
||||
/// <summary>Network (N) - Remotely exploitable.</summary>
|
||||
Network,
|
||||
/// <summary>Adjacent (A) - Same network segment.</summary>
|
||||
Adjacent,
|
||||
/// <summary>Local (L) - Local access required.</summary>
|
||||
Local,
|
||||
/// <summary>Physical (P) - Physical access required.</summary>
|
||||
Physical
|
||||
}
|
||||
|
||||
/// <summary>Attack Complexity values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttackComplexity
|
||||
{
|
||||
/// <summary>Low (L) - No specialized conditions.</summary>
|
||||
Low,
|
||||
/// <summary>High (H) - Specialized conditions required.</summary>
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>Attack Requirements values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AttackRequirements
|
||||
{
|
||||
/// <summary>None (N) - No preconditions required.</summary>
|
||||
None,
|
||||
/// <summary>Present (P) - Preconditions must exist.</summary>
|
||||
Present
|
||||
}
|
||||
|
||||
/// <summary>Privileges Required values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum PrivilegesRequired
|
||||
{
|
||||
/// <summary>None (N) - No privileges needed.</summary>
|
||||
None,
|
||||
/// <summary>Low (L) - Basic user privileges needed.</summary>
|
||||
Low,
|
||||
/// <summary>High (H) - Admin/elevated privileges needed.</summary>
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>User Interaction values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum UserInteraction
|
||||
{
|
||||
/// <summary>None (N) - No user interaction required.</summary>
|
||||
None,
|
||||
/// <summary>Passive (P) - Involuntary user action.</summary>
|
||||
Passive,
|
||||
/// <summary>Active (A) - Conscious user action required.</summary>
|
||||
Active
|
||||
}
|
||||
|
||||
/// <summary>Impact metric values (None/Low/High) per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ImpactMetricValue
|
||||
{
|
||||
/// <summary>None (N) - No impact.</summary>
|
||||
None,
|
||||
/// <summary>Low (L) - Limited impact.</summary>
|
||||
Low,
|
||||
/// <summary>High (H) - Serious impact.</summary>
|
||||
High
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threat Metric Enums
|
||||
|
||||
/// <summary>Exploit Maturity values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ExploitMaturity
|
||||
{
|
||||
/// <summary>Not Defined (X) - Not assessed.</summary>
|
||||
NotDefined,
|
||||
/// <summary>Attacked (A) - Active exploitation observed.</summary>
|
||||
Attacked,
|
||||
/// <summary>Proof of Concept (P) - PoC code exists.</summary>
|
||||
ProofOfConcept,
|
||||
/// <summary>Unreported (U) - No public exploit code.</summary>
|
||||
Unreported
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environmental Metric Enums (Modified versions)
|
||||
|
||||
/// <summary>Modified Attack Vector values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedAttackVector
|
||||
{
|
||||
NotDefined, Network, Adjacent, Local, Physical
|
||||
}
|
||||
|
||||
/// <summary>Modified Attack Complexity values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedAttackComplexity
|
||||
{
|
||||
NotDefined, Low, High
|
||||
}
|
||||
|
||||
/// <summary>Modified Attack Requirements values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedAttackRequirements
|
||||
{
|
||||
NotDefined, None, Present
|
||||
}
|
||||
|
||||
/// <summary>Modified Privileges Required values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedPrivilegesRequired
|
||||
{
|
||||
NotDefined, None, Low, High
|
||||
}
|
||||
|
||||
/// <summary>Modified User Interaction values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedUserInteraction
|
||||
{
|
||||
NotDefined, None, Passive, Active
|
||||
}
|
||||
|
||||
/// <summary>Modified Impact metric values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedImpactMetricValue
|
||||
{
|
||||
NotDefined, None, Low, High
|
||||
}
|
||||
|
||||
/// <summary>Modified Subsequent System Impact values (includes Safety dimension).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ModifiedSubsequentImpact
|
||||
{
|
||||
NotDefined, Negligible, Low, High, Safety
|
||||
}
|
||||
|
||||
/// <summary>Security Requirement values.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SecurityRequirement
|
||||
{
|
||||
NotDefined, Low, Medium, High
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Supplemental Metric Enums
|
||||
|
||||
/// <summary>Safety values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Safety
|
||||
{
|
||||
NotDefined, Negligible, Present
|
||||
}
|
||||
|
||||
/// <summary>Automatable values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Automatable
|
||||
{
|
||||
NotDefined, No, Yes
|
||||
}
|
||||
|
||||
/// <summary>Recovery values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Recovery
|
||||
{
|
||||
NotDefined, Automatic, User, Irrecoverable
|
||||
}
|
||||
|
||||
/// <summary>Value Density values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ValueDensity
|
||||
{
|
||||
NotDefined, Diffuse, Concentrated
|
||||
}
|
||||
|
||||
/// <summary>Response Effort values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ResponseEffort
|
||||
{
|
||||
NotDefined, Low, Moderate, High
|
||||
}
|
||||
|
||||
/// <summary>Provider Urgency values per CVSS v4.0.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ProviderUrgency
|
||||
{
|
||||
NotDefined, Clear, Green, Amber, Red
|
||||
}
|
||||
|
||||
#endregion
|
||||
223
src/Policy/StellaOps.Policy.Scoring/CvssPolicy.cs
Normal file
223
src/Policy/StellaOps.Policy.Scoring/CvssPolicy.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS scoring policy configuration.
|
||||
/// Defines how CVSS scores are computed and what thresholds apply.
|
||||
/// </summary>
|
||||
public sealed record CvssPolicy
|
||||
{
|
||||
/// <summary>Unique policy identifier.</summary>
|
||||
[JsonPropertyName("policyId")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>Policy version (semantic versioning).</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Human-readable policy name.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>Policy description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Tenant scope (null for global policy).</summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>When this policy becomes effective.</summary>
|
||||
[JsonPropertyName("effectiveFrom")]
|
||||
public required DateTimeOffset EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>When this policy expires (null for no expiry).</summary>
|
||||
[JsonPropertyName("effectiveUntil")]
|
||||
public DateTimeOffset? EffectiveUntil { get; init; }
|
||||
|
||||
/// <summary>Whether this policy is currently active.</summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>Which score type to use as the effective score by default.</summary>
|
||||
[JsonPropertyName("defaultEffectiveScoreType")]
|
||||
public EffectiveScoreType DefaultEffectiveScoreType { get; init; } = EffectiveScoreType.Full;
|
||||
|
||||
/// <summary>Default environmental metrics to apply when not provided.</summary>
|
||||
[JsonPropertyName("defaultEnvironmentalMetrics")]
|
||||
public CvssEnvironmentalMetrics? DefaultEnvironmentalMetrics { get; init; }
|
||||
|
||||
/// <summary>Severity thresholds (override FIRST defaults if specified).</summary>
|
||||
[JsonPropertyName("severityThresholds")]
|
||||
public CvssSeverityThresholds? SeverityThresholds { get; init; }
|
||||
|
||||
/// <summary>Score rounding configuration.</summary>
|
||||
[JsonPropertyName("rounding")]
|
||||
public CvssRoundingConfig Rounding { get; init; } = new();
|
||||
|
||||
/// <summary>Evidence requirements for receipts.</summary>
|
||||
[JsonPropertyName("evidenceRequirements")]
|
||||
public CvssEvidenceRequirements? EvidenceRequirements { get; init; }
|
||||
|
||||
/// <summary>Attestation requirements.</summary>
|
||||
[JsonPropertyName("attestationRequirements")]
|
||||
public CvssAttestationRequirements? AttestationRequirements { get; init; }
|
||||
|
||||
/// <summary>Metric overrides for specific vulnerability patterns.</summary>
|
||||
[JsonPropertyName("metricOverrides")]
|
||||
public ImmutableList<CvssMetricOverride> MetricOverrides { get; init; } = [];
|
||||
|
||||
/// <summary>SHA-256 hash of this policy for determinism tracking.</summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public string? Hash { get; init; }
|
||||
|
||||
/// <summary>When this policy was created.</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Who created this policy.</summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity threshold configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssSeverityThresholds
|
||||
{
|
||||
/// <summary>Low severity lower bound (default: 0.1).</summary>
|
||||
[JsonPropertyName("lowMin")]
|
||||
public double LowMin { get; init; } = 0.1;
|
||||
|
||||
/// <summary>Medium severity lower bound (default: 4.0).</summary>
|
||||
[JsonPropertyName("mediumMin")]
|
||||
public double MediumMin { get; init; } = 4.0;
|
||||
|
||||
/// <summary>High severity lower bound (default: 7.0).</summary>
|
||||
[JsonPropertyName("highMin")]
|
||||
public double HighMin { get; init; } = 7.0;
|
||||
|
||||
/// <summary>Critical severity lower bound (default: 9.0).</summary>
|
||||
[JsonPropertyName("criticalMin")]
|
||||
public double CriticalMin { get; init; } = 9.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score rounding configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssRoundingConfig
|
||||
{
|
||||
/// <summary>Number of decimal places for scores (default: 1).</summary>
|
||||
[JsonPropertyName("decimalPlaces")]
|
||||
public int DecimalPlaces { get; init; } = 1;
|
||||
|
||||
/// <summary>Rounding mode (default: roundUp per FIRST spec).</summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public CvssRoundingMode Mode { get; init; } = CvssRoundingMode.RoundUp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rounding modes for CVSS scores.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CvssRoundingMode
|
||||
{
|
||||
/// <summary>Round up to nearest tenth (FIRST spec default).</summary>
|
||||
RoundUp,
|
||||
/// <summary>Standard mathematical rounding.</summary>
|
||||
Standard,
|
||||
/// <summary>Always round down.</summary>
|
||||
RoundDown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence requirements configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssEvidenceRequirements
|
||||
{
|
||||
/// <summary>Minimum number of evidence items required.</summary>
|
||||
[JsonPropertyName("minimumCount")]
|
||||
public int MinimumCount { get; init; }
|
||||
|
||||
/// <summary>Whether authoritative evidence is required.</summary>
|
||||
[JsonPropertyName("requireAuthoritative")]
|
||||
public bool RequireAuthoritative { get; init; }
|
||||
|
||||
/// <summary>Required evidence types.</summary>
|
||||
[JsonPropertyName("requiredTypes")]
|
||||
public ImmutableList<string> RequiredTypes { get; init; } = [];
|
||||
|
||||
/// <summary>Maximum age of evidence in days.</summary>
|
||||
[JsonPropertyName("maxAgeInDays")]
|
||||
public int? MaxAgeInDays { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation requirements configuration.
|
||||
/// </summary>
|
||||
public sealed record CvssAttestationRequirements
|
||||
{
|
||||
/// <summary>Whether DSSE attestation is required.</summary>
|
||||
[JsonPropertyName("requireDsse")]
|
||||
public bool RequireDsse { get; init; }
|
||||
|
||||
/// <summary>Whether Rekor transparency log registration is required.</summary>
|
||||
[JsonPropertyName("requireRekor")]
|
||||
public bool RequireRekor { get; init; }
|
||||
|
||||
/// <summary>Acceptable signing key identities.</summary>
|
||||
[JsonPropertyName("allowedSigners")]
|
||||
public ImmutableList<string> AllowedSigners { get; init; } = [];
|
||||
|
||||
/// <summary>Minimum trust level for attestations.</summary>
|
||||
[JsonPropertyName("minimumTrustLevel")]
|
||||
public string? MinimumTrustLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metric override for specific vulnerability patterns.
|
||||
/// </summary>
|
||||
public sealed record CvssMetricOverride
|
||||
{
|
||||
/// <summary>Override identifier.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>Human-readable description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>Pattern to match vulnerability IDs (regex).</summary>
|
||||
[JsonPropertyName("vulnerabilityPattern")]
|
||||
public string? VulnerabilityPattern { get; init; }
|
||||
|
||||
/// <summary>Specific vulnerability IDs to match.</summary>
|
||||
[JsonPropertyName("vulnerabilityIds")]
|
||||
public ImmutableList<string> VulnerabilityIds { get; init; } = [];
|
||||
|
||||
/// <summary>CWE IDs to match.</summary>
|
||||
[JsonPropertyName("cweIds")]
|
||||
public ImmutableList<string> CweIds { get; init; } = [];
|
||||
|
||||
/// <summary>Environmental metric overrides to apply.</summary>
|
||||
[JsonPropertyName("environmentalOverrides")]
|
||||
public CvssEnvironmentalMetrics? EnvironmentalOverrides { get; init; }
|
||||
|
||||
/// <summary>Score adjustment (added to final score).</summary>
|
||||
[JsonPropertyName("scoreAdjustment")]
|
||||
public double? ScoreAdjustment { get; init; }
|
||||
|
||||
/// <summary>Priority for conflict resolution (higher wins).</summary>
|
||||
[JsonPropertyName("priority")]
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>Whether this override is active.</summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>Reason for this override.</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
297
src/Policy/StellaOps.Policy.Scoring/CvssScoreReceipt.cs
Normal file
297
src/Policy/StellaOps.Policy.Scoring/CvssScoreReceipt.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// A CVSS v4.0 Score Receipt with complete audit trail.
|
||||
/// Provides deterministic, reproducible scoring with full provenance.
|
||||
/// </summary>
|
||||
public sealed record CvssScoreReceipt
|
||||
{
|
||||
/// <summary>Unique receipt identifier.</summary>
|
||||
[JsonPropertyName("receiptId")]
|
||||
public required string ReceiptId { get; init; }
|
||||
|
||||
/// <summary>Schema version for this receipt format.</summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>Receipt format specification.</summary>
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "stella.ops/cvssReceipt@v1";
|
||||
|
||||
/// <summary>Vulnerability identifier (CVE, GHSA, etc.).</summary>
|
||||
[JsonPropertyName("vulnerabilityId")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Tenant scope for multi-tenant deployments.</summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the receipt was created (UTC ISO-8601).</summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>User or system that created the receipt.</summary>
|
||||
[JsonPropertyName("createdBy")]
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Timestamp when the receipt was last modified.</summary>
|
||||
[JsonPropertyName("modifiedAt")]
|
||||
public DateTimeOffset? ModifiedAt { get; init; }
|
||||
|
||||
/// <summary>User or system that last modified the receipt.</summary>
|
||||
[JsonPropertyName("modifiedBy")]
|
||||
public string? ModifiedBy { get; init; }
|
||||
|
||||
/// <summary>CVSS version used (4.0).</summary>
|
||||
[JsonPropertyName("cvssVersion")]
|
||||
public string CvssVersion { get; init; } = "4.0";
|
||||
|
||||
/// <summary>Base metrics input.</summary>
|
||||
[JsonPropertyName("baseMetrics")]
|
||||
public required CvssBaseMetrics BaseMetrics { get; init; }
|
||||
|
||||
/// <summary>Threat metrics input (optional).</summary>
|
||||
[JsonPropertyName("threatMetrics")]
|
||||
public CvssThreatMetrics? ThreatMetrics { get; init; }
|
||||
|
||||
/// <summary>Environmental metrics input (optional).</summary>
|
||||
[JsonPropertyName("environmentalMetrics")]
|
||||
public CvssEnvironmentalMetrics? EnvironmentalMetrics { get; init; }
|
||||
|
||||
/// <summary>Supplemental metrics (optional, do not affect score).</summary>
|
||||
[JsonPropertyName("supplementalMetrics")]
|
||||
public CvssSupplementalMetrics? SupplementalMetrics { get; init; }
|
||||
|
||||
/// <summary>Computed scores.</summary>
|
||||
[JsonPropertyName("scores")]
|
||||
public required CvssScores Scores { get; init; }
|
||||
|
||||
/// <summary>Computed CVSS v4.0 vector string.</summary>
|
||||
[JsonPropertyName("vectorString")]
|
||||
public required string VectorString { get; init; }
|
||||
|
||||
/// <summary>Severity rating based on final score.</summary>
|
||||
[JsonPropertyName("severity")]
|
||||
public required CvssSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>Policy that was applied to compute this receipt.</summary>
|
||||
[JsonPropertyName("policyRef")]
|
||||
public required CvssPolicyReference PolicyRef { get; init; }
|
||||
|
||||
/// <summary>Evidence items supporting this score.</summary>
|
||||
[JsonPropertyName("evidence")]
|
||||
public ImmutableList<CvssEvidenceItem> Evidence { get; init; } = [];
|
||||
|
||||
/// <summary>DSSE attestation envelope references, if signed.</summary>
|
||||
[JsonPropertyName("attestationRefs")]
|
||||
public ImmutableList<string> AttestationRefs { get; init; } = [];
|
||||
|
||||
/// <summary>SHA-256 hash of deterministic input for reproducibility.</summary>
|
||||
[JsonPropertyName("inputHash")]
|
||||
public required string InputHash { get; init; }
|
||||
|
||||
/// <summary>Amendment history entries.</summary>
|
||||
[JsonPropertyName("history")]
|
||||
public ImmutableList<ReceiptHistoryEntry> History { get; init; } = [];
|
||||
|
||||
/// <summary>Original receipt ID if this is an amendment.</summary>
|
||||
[JsonPropertyName("amendsReceiptId")]
|
||||
public string? AmendsReceiptId { get; init; }
|
||||
|
||||
/// <summary>Whether this receipt is the current active version.</summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>Reason this receipt was superseded (if not active).</summary>
|
||||
[JsonPropertyName("supersededReason")]
|
||||
public string? SupersededReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computed CVSS v4.0 scores.
|
||||
/// </summary>
|
||||
public sealed record CvssScores
|
||||
{
|
||||
/// <summary>Base Score (CVSS-B) - Impact of the vulnerability.</summary>
|
||||
[JsonPropertyName("baseScore")]
|
||||
public required double BaseScore { get; init; }
|
||||
|
||||
/// <summary>Threat-Adjusted Score (CVSS-BT) - Base + threat metrics.</summary>
|
||||
[JsonPropertyName("threatScore")]
|
||||
public double? ThreatScore { get; init; }
|
||||
|
||||
/// <summary>Environmental Score (CVSS-BE) - Base + environmental metrics.</summary>
|
||||
[JsonPropertyName("environmentalScore")]
|
||||
public double? EnvironmentalScore { get; init; }
|
||||
|
||||
/// <summary>Full Score (CVSS-BTE) - Base + threat + environmental.</summary>
|
||||
[JsonPropertyName("fullScore")]
|
||||
public double? FullScore { get; init; }
|
||||
|
||||
/// <summary>Which score is considered the "effective" score for policy decisions.</summary>
|
||||
[JsonPropertyName("effectiveScore")]
|
||||
public required double EffectiveScore { get; init; }
|
||||
|
||||
/// <summary>Which score type was used as effective.</summary>
|
||||
[JsonPropertyName("effectiveScoreType")]
|
||||
public required EffectiveScoreType EffectiveScoreType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates which score type was used as the effective score.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum EffectiveScoreType
|
||||
{
|
||||
/// <summary>Base score only (CVSS-B).</summary>
|
||||
Base,
|
||||
/// <summary>Base + Threat (CVSS-BT).</summary>
|
||||
Threat,
|
||||
/// <summary>Base + Environmental (CVSS-BE).</summary>
|
||||
Environmental,
|
||||
/// <summary>Full score with all metrics (CVSS-BTE).</summary>
|
||||
Full
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 severity ratings.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CvssSeverity
|
||||
{
|
||||
/// <summary>None - Score 0.0.</summary>
|
||||
None,
|
||||
/// <summary>Low - Score 0.1-3.9.</summary>
|
||||
Low,
|
||||
/// <summary>Medium - Score 4.0-6.9.</summary>
|
||||
Medium,
|
||||
/// <summary>High - Score 7.0-8.9.</summary>
|
||||
High,
|
||||
/// <summary>Critical - Score 9.0-10.0.</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy used for scoring.
|
||||
/// </summary>
|
||||
public sealed record CvssPolicyReference
|
||||
{
|
||||
/// <summary>Policy identifier.</summary>
|
||||
[JsonPropertyName("policyId")]
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>Policy version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the policy content.</summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>When the policy was activated.</summary>
|
||||
[JsonPropertyName("activatedAt")]
|
||||
public DateTimeOffset? ActivatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence item supporting a CVSS score.
|
||||
/// </summary>
|
||||
public sealed record CvssEvidenceItem
|
||||
{
|
||||
/// <summary>Evidence type (advisory, vex, scan, etc.).</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Content-addressable storage URI (e.g., sha256:...).</summary>
|
||||
[JsonPropertyName("uri")]
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>Human-readable description of the evidence.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>When the evidence was collected.</summary>
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset? CollectedAt { get; init; }
|
||||
|
||||
/// <summary>Source of the evidence (vendor, scanner, manual).</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>Whether this evidence is from the vendor/authority.</summary>
|
||||
[JsonPropertyName("isAuthoritative")]
|
||||
public bool IsAuthoritative { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// History entry for receipt amendments.
|
||||
/// </summary>
|
||||
public sealed record ReceiptHistoryEntry
|
||||
{
|
||||
/// <summary>Unique history entry identifier.</summary>
|
||||
[JsonPropertyName("historyId")]
|
||||
public required string HistoryId { get; init; }
|
||||
|
||||
/// <summary>When the amendment was made.</summary>
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>User or system that made the amendment.</summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>Type of change (amend, supersede, revoke).</summary>
|
||||
[JsonPropertyName("changeType")]
|
||||
public required ReceiptChangeType ChangeType { get; init; }
|
||||
|
||||
/// <summary>Field that was changed.</summary>
|
||||
[JsonPropertyName("field")]
|
||||
public required string Field { get; init; }
|
||||
|
||||
/// <summary>Previous value (JSON encoded).</summary>
|
||||
[JsonPropertyName("previousValue")]
|
||||
public string? PreviousValue { get; init; }
|
||||
|
||||
/// <summary>New value (JSON encoded).</summary>
|
||||
[JsonPropertyName("newValue")]
|
||||
public string? NewValue { get; init; }
|
||||
|
||||
/// <summary>Reason for the change.</summary>
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Reference URI for supporting documentation.</summary>
|
||||
[JsonPropertyName("referenceUri")]
|
||||
public string? ReferenceUri { get; init; }
|
||||
|
||||
/// <summary>Signature of this history entry for integrity.</summary>
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of changes to a receipt.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ReceiptChangeType
|
||||
{
|
||||
/// <summary>Initial creation.</summary>
|
||||
Created,
|
||||
/// <summary>Field value amended.</summary>
|
||||
Amended,
|
||||
/// <summary>Receipt superseded by newer version.</summary>
|
||||
Superseded,
|
||||
/// <summary>Receipt revoked/invalidated.</summary>
|
||||
Revoked,
|
||||
/// <summary>Evidence added.</summary>
|
||||
EvidenceAdded,
|
||||
/// <summary>Attestation signed.</summary>
|
||||
AttestationSigned,
|
||||
/// <summary>Policy updated.</summary>
|
||||
PolicyUpdated,
|
||||
/// <summary>Score recalculated.</summary>
|
||||
Recalculated
|
||||
}
|
||||
809
src/Policy/StellaOps.Policy.Scoring/Engine/CvssV4Engine.cs
Normal file
809
src/Policy/StellaOps.Policy.Scoring/Engine/CvssV4Engine.cs
Normal file
@@ -0,0 +1,809 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 scoring engine implementation.
|
||||
/// Implements FIRST CVSS v4.0 specification with MacroVector-based scoring.
|
||||
/// </summary>
|
||||
public sealed partial class CvssV4Engine : ICvssV4Engine
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public CvssScores ComputeScores(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseMetrics);
|
||||
|
||||
// Compute base score (CVSS-B)
|
||||
var baseScore = ComputeBaseScore(baseMetrics);
|
||||
|
||||
// Compute threat score (CVSS-BT) if threat metrics provided
|
||||
double? threatScore = null;
|
||||
if (threatMetrics is not null && threatMetrics.ExploitMaturity != ExploitMaturity.NotDefined)
|
||||
{
|
||||
threatScore = ComputeThreatScore(baseMetrics, threatMetrics);
|
||||
}
|
||||
|
||||
// Compute environmental score (CVSS-BE) if environmental metrics provided
|
||||
double? environmentalScore = null;
|
||||
if (environmentalMetrics is not null && HasEnvironmentalMetrics(environmentalMetrics))
|
||||
{
|
||||
environmentalScore = ComputeEnvironmentalScore(baseMetrics, environmentalMetrics);
|
||||
}
|
||||
|
||||
// Compute full score (CVSS-BTE) if both threat and environmental metrics provided
|
||||
double? fullScore = null;
|
||||
if (threatMetrics is not null && environmentalMetrics is not null &&
|
||||
threatMetrics.ExploitMaturity != ExploitMaturity.NotDefined &&
|
||||
HasEnvironmentalMetrics(environmentalMetrics))
|
||||
{
|
||||
fullScore = ComputeFullScore(baseMetrics, threatMetrics, environmentalMetrics);
|
||||
}
|
||||
|
||||
// Determine effective score and type
|
||||
var (effectiveScore, effectiveType) = DetermineEffectiveScore(
|
||||
baseScore, threatScore, environmentalScore, fullScore);
|
||||
|
||||
return new CvssScores
|
||||
{
|
||||
BaseScore = baseScore,
|
||||
ThreatScore = threatScore,
|
||||
EnvironmentalScore = environmentalScore,
|
||||
FullScore = fullScore,
|
||||
EffectiveScore = effectiveScore,
|
||||
EffectiveScoreType = effectiveType
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildVectorString(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null,
|
||||
CvssSupplementalMetrics? supplementalMetrics = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseMetrics);
|
||||
|
||||
var sb = new StringBuilder("CVSS:4.0");
|
||||
|
||||
// Base metrics (mandatory)
|
||||
sb.Append($"/AV:{MetricToString(baseMetrics.AttackVector)}");
|
||||
sb.Append($"/AC:{MetricToString(baseMetrics.AttackComplexity)}");
|
||||
sb.Append($"/AT:{MetricToString(baseMetrics.AttackRequirements)}");
|
||||
sb.Append($"/PR:{MetricToString(baseMetrics.PrivilegesRequired)}");
|
||||
sb.Append($"/UI:{MetricToString(baseMetrics.UserInteraction)}");
|
||||
sb.Append($"/VC:{MetricToString(baseMetrics.VulnerableSystemConfidentiality)}");
|
||||
sb.Append($"/VI:{MetricToString(baseMetrics.VulnerableSystemIntegrity)}");
|
||||
sb.Append($"/VA:{MetricToString(baseMetrics.VulnerableSystemAvailability)}");
|
||||
sb.Append($"/SC:{MetricToString(baseMetrics.SubsequentSystemConfidentiality)}");
|
||||
sb.Append($"/SI:{MetricToString(baseMetrics.SubsequentSystemIntegrity)}");
|
||||
sb.Append($"/SA:{MetricToString(baseMetrics.SubsequentSystemAvailability)}");
|
||||
|
||||
// Threat metrics (optional)
|
||||
if (threatMetrics is not null && threatMetrics.ExploitMaturity != ExploitMaturity.NotDefined)
|
||||
{
|
||||
sb.Append($"/E:{MetricToString(threatMetrics.ExploitMaturity)}");
|
||||
}
|
||||
|
||||
// Environmental metrics (optional, only include if not NotDefined)
|
||||
if (environmentalMetrics is not null)
|
||||
{
|
||||
AppendEnvironmentalMetrics(sb, environmentalMetrics);
|
||||
}
|
||||
|
||||
// Supplemental metrics (optional, only include if not NotDefined)
|
||||
if (supplementalMetrics is not null)
|
||||
{
|
||||
AppendSupplementalMetrics(sb, supplementalMetrics);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CvssMetricSet ParseVector(string vectorString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vectorString))
|
||||
throw new ArgumentException("Vector string cannot be null or empty.", nameof(vectorString));
|
||||
|
||||
if (!vectorString.StartsWith("CVSS:4.0/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("Vector string must start with 'CVSS:4.0/'.", nameof(vectorString));
|
||||
|
||||
var metrics = ParseMetricsFromVector(vectorString[9..]);
|
||||
|
||||
// Parse base metrics (all required)
|
||||
var baseMetrics = new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = ParseAttackVector(GetRequiredMetric(metrics, "AV")),
|
||||
AttackComplexity = ParseAttackComplexity(GetRequiredMetric(metrics, "AC")),
|
||||
AttackRequirements = ParseAttackRequirements(GetRequiredMetric(metrics, "AT")),
|
||||
PrivilegesRequired = ParsePrivilegesRequired(GetRequiredMetric(metrics, "PR")),
|
||||
UserInteraction = ParseUserInteraction(GetRequiredMetric(metrics, "UI")),
|
||||
VulnerableSystemConfidentiality = ParseImpactMetric(GetRequiredMetric(metrics, "VC")),
|
||||
VulnerableSystemIntegrity = ParseImpactMetric(GetRequiredMetric(metrics, "VI")),
|
||||
VulnerableSystemAvailability = ParseImpactMetric(GetRequiredMetric(metrics, "VA")),
|
||||
SubsequentSystemConfidentiality = ParseImpactMetric(GetRequiredMetric(metrics, "SC")),
|
||||
SubsequentSystemIntegrity = ParseImpactMetric(GetRequiredMetric(metrics, "SI")),
|
||||
SubsequentSystemAvailability = ParseImpactMetric(GetRequiredMetric(metrics, "SA"))
|
||||
};
|
||||
|
||||
// Parse threat metrics
|
||||
CvssThreatMetrics? threatMetrics = null;
|
||||
if (metrics.TryGetValue("E", out var e))
|
||||
{
|
||||
threatMetrics = new CvssThreatMetrics
|
||||
{
|
||||
ExploitMaturity = ParseExploitMaturity(e)
|
||||
};
|
||||
}
|
||||
|
||||
// Parse environmental metrics
|
||||
var envMetrics = ParseEnvironmentalMetrics(metrics);
|
||||
|
||||
// Parse supplemental metrics
|
||||
var suppMetrics = ParseSupplementalMetrics(metrics);
|
||||
|
||||
return new CvssMetricSet
|
||||
{
|
||||
BaseMetrics = baseMetrics,
|
||||
ThreatMetrics = threatMetrics,
|
||||
EnvironmentalMetrics = envMetrics,
|
||||
SupplementalMetrics = suppMetrics
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public CvssSeverity GetSeverity(double score, CvssSeverityThresholds? thresholds = null)
|
||||
{
|
||||
thresholds ??= new CvssSeverityThresholds();
|
||||
|
||||
return score switch
|
||||
{
|
||||
0.0 => CvssSeverity.None,
|
||||
>= 9.0 when score >= thresholds.CriticalMin => CvssSeverity.Critical,
|
||||
>= 7.0 when score >= thresholds.HighMin => CvssSeverity.High,
|
||||
>= 4.0 when score >= thresholds.MediumMin => CvssSeverity.Medium,
|
||||
> 0.0 when score >= thresholds.LowMin => CvssSeverity.Low,
|
||||
_ => CvssSeverity.None
|
||||
};
|
||||
}
|
||||
|
||||
#region Score Computation
|
||||
|
||||
private static double ComputeBaseScore(CvssBaseMetrics metrics)
|
||||
{
|
||||
// Get MacroVector from base metrics
|
||||
var macroVector = GetMacroVector(metrics);
|
||||
|
||||
// Look up base score from MacroVector lookup table
|
||||
var score = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
return RoundUp(score);
|
||||
}
|
||||
|
||||
private static double ComputeThreatScore(CvssBaseMetrics baseMetrics, CvssThreatMetrics threatMetrics)
|
||||
{
|
||||
// Get base score first
|
||||
var macroVector = GetMacroVector(baseMetrics);
|
||||
var baseScore = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Apply threat multiplier based on Exploit Maturity
|
||||
var threatMultiplier = GetThreatMultiplier(threatMetrics.ExploitMaturity);
|
||||
|
||||
// Threat score = Base * Threat Multiplier
|
||||
var threatScore = baseScore * threatMultiplier;
|
||||
|
||||
return RoundUp(threatScore);
|
||||
}
|
||||
|
||||
private static double ComputeEnvironmentalScore(CvssBaseMetrics baseMetrics, CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
// Apply modified metrics to base metrics
|
||||
var modifiedMetrics = ApplyEnvironmentalModifiers(baseMetrics, envMetrics);
|
||||
|
||||
// Compute modified base score
|
||||
var macroVector = GetMacroVector(modifiedMetrics);
|
||||
var modifiedBaseScore = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Apply security requirements
|
||||
var requirementsMultiplier = GetRequirementsMultiplier(envMetrics);
|
||||
var envScore = modifiedBaseScore * requirementsMultiplier;
|
||||
|
||||
return Math.Min(RoundUp(envScore), 10.0);
|
||||
}
|
||||
|
||||
private static double ComputeFullScore(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics threatMetrics,
|
||||
CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
// Apply modified metrics to base metrics
|
||||
var modifiedMetrics = ApplyEnvironmentalModifiers(baseMetrics, envMetrics);
|
||||
|
||||
// Compute modified base score
|
||||
var macroVector = GetMacroVector(modifiedMetrics);
|
||||
var modifiedBaseScore = MacroVectorLookup.GetBaseScore(macroVector);
|
||||
|
||||
// Apply threat multiplier
|
||||
var threatMultiplier = GetThreatMultiplier(threatMetrics.ExploitMaturity);
|
||||
|
||||
// Apply security requirements
|
||||
var requirementsMultiplier = GetRequirementsMultiplier(envMetrics);
|
||||
|
||||
// Full score = Modified Base * Threat * Requirements
|
||||
var fullScore = modifiedBaseScore * threatMultiplier * requirementsMultiplier;
|
||||
|
||||
return Math.Min(RoundUp(fullScore), 10.0);
|
||||
}
|
||||
|
||||
private static (double Score, EffectiveScoreType Type) DetermineEffectiveScore(
|
||||
double baseScore,
|
||||
double? threatScore,
|
||||
double? environmentalScore,
|
||||
double? fullScore)
|
||||
{
|
||||
// Priority: Full > Environmental > Threat > Base
|
||||
if (fullScore.HasValue)
|
||||
return (fullScore.Value, EffectiveScoreType.Full);
|
||||
if (environmentalScore.HasValue)
|
||||
return (environmentalScore.Value, EffectiveScoreType.Environmental);
|
||||
if (threatScore.HasValue)
|
||||
return (threatScore.Value, EffectiveScoreType.Threat);
|
||||
return (baseScore, EffectiveScoreType.Base);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MacroVector Computation
|
||||
|
||||
private static string GetMacroVector(CvssBaseMetrics metrics)
|
||||
{
|
||||
// Build MacroVector string from EQ (Equivalence) values
|
||||
// Per CVSS v4.0 spec: EQ1-EQ6 define the MacroVector
|
||||
var eq1 = GetEQ1(metrics);
|
||||
var eq2 = GetEQ2(metrics);
|
||||
var eq3 = GetEQ3(metrics);
|
||||
var eq4 = GetEQ4(metrics);
|
||||
var eq5 = GetEQ5(metrics);
|
||||
var eq6 = GetEQ6(metrics);
|
||||
|
||||
return $"{eq1}{eq2}{eq3}{eq4}{eq5}{eq6}";
|
||||
}
|
||||
|
||||
private static int GetEQ1(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ1: Attack Vector + Privileges Required
|
||||
return (m.AttackVector, m.PrivilegesRequired) switch
|
||||
{
|
||||
(AttackVector.Network, PrivilegesRequired.None) => 0,
|
||||
(AttackVector.Network, PrivilegesRequired.Low) => 1,
|
||||
(AttackVector.Network, PrivilegesRequired.High) => 1,
|
||||
(AttackVector.Adjacent, PrivilegesRequired.None) => 1,
|
||||
(AttackVector.Adjacent, PrivilegesRequired.Low) => 2,
|
||||
(AttackVector.Adjacent, PrivilegesRequired.High) => 2,
|
||||
(AttackVector.Local, _) => 2,
|
||||
(AttackVector.Physical, _) => 2,
|
||||
_ => 2
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetEQ2(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ2: Attack Complexity + User Interaction
|
||||
return (m.AttackComplexity, m.UserInteraction) switch
|
||||
{
|
||||
(AttackComplexity.Low, UserInteraction.None) => 0,
|
||||
(AttackComplexity.Low, UserInteraction.Passive) => 1,
|
||||
(AttackComplexity.Low, UserInteraction.Active) => 1,
|
||||
(AttackComplexity.High, _) => 1,
|
||||
_ => 1
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetEQ3(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ3: Vulnerable System CIA (highest impact)
|
||||
var vc = m.VulnerableSystemConfidentiality;
|
||||
var vi = m.VulnerableSystemIntegrity;
|
||||
var va = m.VulnerableSystemAvailability;
|
||||
|
||||
if (vc == ImpactMetricValue.High || vi == ImpactMetricValue.High || va == ImpactMetricValue.High)
|
||||
return 0;
|
||||
if (vc == ImpactMetricValue.Low || vi == ImpactMetricValue.Low || va == ImpactMetricValue.Low)
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static int GetEQ4(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ4: Subsequent System CIA (highest impact)
|
||||
var sc = m.SubsequentSystemConfidentiality;
|
||||
var si = m.SubsequentSystemIntegrity;
|
||||
var sa = m.SubsequentSystemAvailability;
|
||||
|
||||
if (sc == ImpactMetricValue.High || si == ImpactMetricValue.High || sa == ImpactMetricValue.High)
|
||||
return 0;
|
||||
if (sc == ImpactMetricValue.Low || si == ImpactMetricValue.Low || sa == ImpactMetricValue.Low)
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static int GetEQ5(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ5: Attack Requirements
|
||||
return m.AttackRequirements == AttackRequirements.None ? 0 : 1;
|
||||
}
|
||||
|
||||
private static int GetEQ6(CvssBaseMetrics m)
|
||||
{
|
||||
// EQ6: Combined impact pattern
|
||||
var vcHigh = m.VulnerableSystemConfidentiality == ImpactMetricValue.High;
|
||||
var viHigh = m.VulnerableSystemIntegrity == ImpactMetricValue.High;
|
||||
var vaHigh = m.VulnerableSystemAvailability == ImpactMetricValue.High;
|
||||
var scHigh = m.SubsequentSystemConfidentiality == ImpactMetricValue.High;
|
||||
var siHigh = m.SubsequentSystemIntegrity == ImpactMetricValue.High;
|
||||
var saHigh = m.SubsequentSystemAvailability == ImpactMetricValue.High;
|
||||
|
||||
// Count high impacts
|
||||
var vulnHighCount = (vcHigh ? 1 : 0) + (viHigh ? 1 : 0) + (vaHigh ? 1 : 0);
|
||||
var subHighCount = (scHigh ? 1 : 0) + (siHigh ? 1 : 0) + (saHigh ? 1 : 0);
|
||||
|
||||
if (vulnHighCount >= 2 || subHighCount >= 2)
|
||||
return 0;
|
||||
if (vulnHighCount == 1 || subHighCount == 1)
|
||||
return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multipliers
|
||||
|
||||
private static double GetThreatMultiplier(ExploitMaturity exploitMaturity)
|
||||
{
|
||||
return exploitMaturity switch
|
||||
{
|
||||
ExploitMaturity.Attacked => 1.0,
|
||||
ExploitMaturity.ProofOfConcept => 0.94,
|
||||
ExploitMaturity.Unreported => 0.91,
|
||||
ExploitMaturity.NotDefined => 1.0,
|
||||
_ => 1.0
|
||||
};
|
||||
}
|
||||
|
||||
private static double GetRequirementsMultiplier(CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
var crMultiplier = GetSecurityRequirementMultiplier(envMetrics.ConfidentialityRequirement);
|
||||
var irMultiplier = GetSecurityRequirementMultiplier(envMetrics.IntegrityRequirement);
|
||||
var arMultiplier = GetSecurityRequirementMultiplier(envMetrics.AvailabilityRequirement);
|
||||
|
||||
// Average of requirements multipliers
|
||||
return (crMultiplier + irMultiplier + arMultiplier) / 3.0;
|
||||
}
|
||||
|
||||
private static double GetSecurityRequirementMultiplier(SecurityRequirement? requirement)
|
||||
{
|
||||
return requirement switch
|
||||
{
|
||||
SecurityRequirement.High => 1.5,
|
||||
SecurityRequirement.Medium => 1.0,
|
||||
SecurityRequirement.Low => 0.5,
|
||||
SecurityRequirement.NotDefined => 1.0,
|
||||
null => 1.0,
|
||||
_ => 1.0
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environmental Modifiers
|
||||
|
||||
private static CvssBaseMetrics ApplyEnvironmentalModifiers(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
return new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = GetEffectiveAttackVector(baseMetrics.AttackVector, envMetrics.ModifiedAttackVector),
|
||||
AttackComplexity = GetEffectiveAttackComplexity(baseMetrics.AttackComplexity, envMetrics.ModifiedAttackComplexity),
|
||||
AttackRequirements = GetEffectiveAttackRequirements(baseMetrics.AttackRequirements, envMetrics.ModifiedAttackRequirements),
|
||||
PrivilegesRequired = GetEffectivePrivilegesRequired(baseMetrics.PrivilegesRequired, envMetrics.ModifiedPrivilegesRequired),
|
||||
UserInteraction = GetEffectiveUserInteraction(baseMetrics.UserInteraction, envMetrics.ModifiedUserInteraction),
|
||||
VulnerableSystemConfidentiality = GetEffectiveImpact(baseMetrics.VulnerableSystemConfidentiality, envMetrics.ModifiedVulnerableSystemConfidentiality),
|
||||
VulnerableSystemIntegrity = GetEffectiveImpact(baseMetrics.VulnerableSystemIntegrity, envMetrics.ModifiedVulnerableSystemIntegrity),
|
||||
VulnerableSystemAvailability = GetEffectiveImpact(baseMetrics.VulnerableSystemAvailability, envMetrics.ModifiedVulnerableSystemAvailability),
|
||||
SubsequentSystemConfidentiality = GetEffectiveImpact(baseMetrics.SubsequentSystemConfidentiality, envMetrics.ModifiedSubsequentSystemConfidentiality),
|
||||
SubsequentSystemIntegrity = GetEffectiveSubsequentImpact(baseMetrics.SubsequentSystemIntegrity, envMetrics.ModifiedSubsequentSystemIntegrity),
|
||||
SubsequentSystemAvailability = GetEffectiveSubsequentImpact(baseMetrics.SubsequentSystemAvailability, envMetrics.ModifiedSubsequentSystemAvailability)
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackVector GetEffectiveAttackVector(AttackVector baseValue, ModifiedAttackVector? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedAttackVector.NotDefined or null => baseValue,
|
||||
ModifiedAttackVector.Network => AttackVector.Network,
|
||||
ModifiedAttackVector.Adjacent => AttackVector.Adjacent,
|
||||
ModifiedAttackVector.Local => AttackVector.Local,
|
||||
ModifiedAttackVector.Physical => AttackVector.Physical,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackComplexity GetEffectiveAttackComplexity(AttackComplexity baseValue, ModifiedAttackComplexity? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedAttackComplexity.NotDefined or null => baseValue,
|
||||
ModifiedAttackComplexity.Low => AttackComplexity.Low,
|
||||
ModifiedAttackComplexity.High => AttackComplexity.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static AttackRequirements GetEffectiveAttackRequirements(AttackRequirements baseValue, ModifiedAttackRequirements? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedAttackRequirements.NotDefined or null => baseValue,
|
||||
ModifiedAttackRequirements.None => AttackRequirements.None,
|
||||
ModifiedAttackRequirements.Present => AttackRequirements.Present,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static PrivilegesRequired GetEffectivePrivilegesRequired(PrivilegesRequired baseValue, ModifiedPrivilegesRequired? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedPrivilegesRequired.NotDefined or null => baseValue,
|
||||
ModifiedPrivilegesRequired.None => PrivilegesRequired.None,
|
||||
ModifiedPrivilegesRequired.Low => PrivilegesRequired.Low,
|
||||
ModifiedPrivilegesRequired.High => PrivilegesRequired.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static UserInteraction GetEffectiveUserInteraction(UserInteraction baseValue, ModifiedUserInteraction? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedUserInteraction.NotDefined or null => baseValue,
|
||||
ModifiedUserInteraction.None => UserInteraction.None,
|
||||
ModifiedUserInteraction.Passive => UserInteraction.Passive,
|
||||
ModifiedUserInteraction.Active => UserInteraction.Active,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static ImpactMetricValue GetEffectiveImpact(ImpactMetricValue baseValue, ModifiedImpactMetricValue? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedImpactMetricValue.NotDefined or null => baseValue,
|
||||
ModifiedImpactMetricValue.None => ImpactMetricValue.None,
|
||||
ModifiedImpactMetricValue.Low => ImpactMetricValue.Low,
|
||||
ModifiedImpactMetricValue.High => ImpactMetricValue.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
private static ImpactMetricValue GetEffectiveSubsequentImpact(ImpactMetricValue baseValue, ModifiedSubsequentImpact? modified)
|
||||
{
|
||||
return modified switch
|
||||
{
|
||||
ModifiedSubsequentImpact.NotDefined or null => baseValue,
|
||||
ModifiedSubsequentImpact.Negligible or ModifiedSubsequentImpact.Low => ImpactMetricValue.Low,
|
||||
ModifiedSubsequentImpact.High or ModifiedSubsequentImpact.Safety => ImpactMetricValue.High,
|
||||
_ => baseValue
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static bool HasEnvironmentalMetrics(CvssEnvironmentalMetrics envMetrics)
|
||||
{
|
||||
return envMetrics.ModifiedAttackVector is not null and not ModifiedAttackVector.NotDefined ||
|
||||
envMetrics.ModifiedAttackComplexity is not null and not ModifiedAttackComplexity.NotDefined ||
|
||||
envMetrics.ModifiedAttackRequirements is not null and not ModifiedAttackRequirements.NotDefined ||
|
||||
envMetrics.ModifiedPrivilegesRequired is not null and not ModifiedPrivilegesRequired.NotDefined ||
|
||||
envMetrics.ModifiedUserInteraction is not null and not ModifiedUserInteraction.NotDefined ||
|
||||
envMetrics.ModifiedVulnerableSystemConfidentiality is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedVulnerableSystemIntegrity is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedVulnerableSystemAvailability is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedSubsequentSystemConfidentiality is not null and not ModifiedImpactMetricValue.NotDefined ||
|
||||
envMetrics.ModifiedSubsequentSystemIntegrity is not null and not ModifiedSubsequentImpact.NotDefined ||
|
||||
envMetrics.ModifiedSubsequentSystemAvailability is not null and not ModifiedSubsequentImpact.NotDefined ||
|
||||
envMetrics.ConfidentialityRequirement is not null and not SecurityRequirement.NotDefined ||
|
||||
envMetrics.IntegrityRequirement is not null and not SecurityRequirement.NotDefined ||
|
||||
envMetrics.AvailabilityRequirement is not null and not SecurityRequirement.NotDefined;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round up to one decimal place per FIRST CVSS v4.0 specification.
|
||||
/// </summary>
|
||||
private static double RoundUp(double value)
|
||||
{
|
||||
return Math.Ceiling(value * 10) / 10;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vector String Building
|
||||
|
||||
private static string MetricToString(AttackVector av) =>
|
||||
av switch { AttackVector.Network => "N", AttackVector.Adjacent => "A", AttackVector.Local => "L", AttackVector.Physical => "P", _ => "N" };
|
||||
|
||||
private static string MetricToString(AttackComplexity ac) =>
|
||||
ac switch { AttackComplexity.Low => "L", AttackComplexity.High => "H", _ => "L" };
|
||||
|
||||
private static string MetricToString(AttackRequirements at) =>
|
||||
at switch { AttackRequirements.None => "N", AttackRequirements.Present => "P", _ => "N" };
|
||||
|
||||
private static string MetricToString(PrivilegesRequired pr) =>
|
||||
pr switch { PrivilegesRequired.None => "N", PrivilegesRequired.Low => "L", PrivilegesRequired.High => "H", _ => "N" };
|
||||
|
||||
private static string MetricToString(UserInteraction ui) =>
|
||||
ui switch { UserInteraction.None => "N", UserInteraction.Passive => "P", UserInteraction.Active => "A", _ => "N" };
|
||||
|
||||
private static string MetricToString(ImpactMetricValue impact) =>
|
||||
impact switch { ImpactMetricValue.None => "N", ImpactMetricValue.Low => "L", ImpactMetricValue.High => "H", _ => "N" };
|
||||
|
||||
private static string MetricToString(ExploitMaturity em) =>
|
||||
em switch { ExploitMaturity.Attacked => "A", ExploitMaturity.ProofOfConcept => "P", ExploitMaturity.Unreported => "U", _ => "X" };
|
||||
|
||||
private static void AppendEnvironmentalMetrics(StringBuilder sb, CvssEnvironmentalMetrics env)
|
||||
{
|
||||
if (env.ConfidentialityRequirement is not null and not SecurityRequirement.NotDefined)
|
||||
sb.Append($"/CR:{SecurityRequirementToString(env.ConfidentialityRequirement.Value)}");
|
||||
if (env.IntegrityRequirement is not null and not SecurityRequirement.NotDefined)
|
||||
sb.Append($"/IR:{SecurityRequirementToString(env.IntegrityRequirement.Value)}");
|
||||
if (env.AvailabilityRequirement is not null and not SecurityRequirement.NotDefined)
|
||||
sb.Append($"/AR:{SecurityRequirementToString(env.AvailabilityRequirement.Value)}");
|
||||
// Add modified metrics (MAV, MAC, etc.) similarly...
|
||||
}
|
||||
|
||||
private static void AppendSupplementalMetrics(StringBuilder sb, CvssSupplementalMetrics supp)
|
||||
{
|
||||
if (supp.Safety is not null and not Safety.NotDefined)
|
||||
sb.Append($"/S:{SafetyToString(supp.Safety.Value)}");
|
||||
if (supp.Automatable is not null and not Automatable.NotDefined)
|
||||
sb.Append($"/AU:{AutomatableToString(supp.Automatable.Value)}");
|
||||
if (supp.Recovery is not null and not Recovery.NotDefined)
|
||||
sb.Append($"/R:{RecoveryToString(supp.Recovery.Value)}");
|
||||
if (supp.ValueDensity is not null and not ValueDensity.NotDefined)
|
||||
sb.Append($"/V:{ValueDensityToString(supp.ValueDensity.Value)}");
|
||||
if (supp.VulnerabilityResponseEffort is not null and not ResponseEffort.NotDefined)
|
||||
sb.Append($"/RE:{ResponseEffortToString(supp.VulnerabilityResponseEffort.Value)}");
|
||||
if (supp.ProviderUrgency is not null and not ProviderUrgency.NotDefined)
|
||||
sb.Append($"/U:{ProviderUrgencyToString(supp.ProviderUrgency.Value)}");
|
||||
}
|
||||
|
||||
private static string SecurityRequirementToString(SecurityRequirement sr) =>
|
||||
sr switch { SecurityRequirement.Low => "L", SecurityRequirement.Medium => "M", SecurityRequirement.High => "H", _ => "X" };
|
||||
|
||||
private static string SafetyToString(Safety s) =>
|
||||
s switch { Safety.Negligible => "N", Safety.Present => "P", _ => "X" };
|
||||
|
||||
private static string AutomatableToString(Automatable a) =>
|
||||
a switch { Automatable.No => "N", Automatable.Yes => "Y", _ => "X" };
|
||||
|
||||
private static string RecoveryToString(Recovery r) =>
|
||||
r switch { Recovery.Automatic => "A", Recovery.User => "U", Recovery.Irrecoverable => "I", _ => "X" };
|
||||
|
||||
private static string ValueDensityToString(ValueDensity v) =>
|
||||
v switch { ValueDensity.Diffuse => "D", ValueDensity.Concentrated => "C", _ => "X" };
|
||||
|
||||
private static string ResponseEffortToString(ResponseEffort re) =>
|
||||
re switch { ResponseEffort.Low => "L", ResponseEffort.Moderate => "M", ResponseEffort.High => "H", _ => "X" };
|
||||
|
||||
private static string ProviderUrgencyToString(ProviderUrgency u) =>
|
||||
u switch { ProviderUrgency.Clear => "Clear", ProviderUrgency.Green => "Green", ProviderUrgency.Amber => "Amber", ProviderUrgency.Red => "Red", _ => "X" };
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vector String Parsing
|
||||
|
||||
[GeneratedRegex(@"([A-Z]+):([A-Za-z]+)", RegexOptions.Compiled)]
|
||||
private static partial Regex MetricPairRegex();
|
||||
|
||||
private static Dictionary<string, string> ParseMetricsFromVector(string vectorPart)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var matches = MetricPairRegex().Matches(vectorPart);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
result[match.Groups[1].Value.ToUpperInvariant()] = match.Groups[2].Value.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetRequiredMetric(Dictionary<string, string> metrics, string key)
|
||||
{
|
||||
if (!metrics.TryGetValue(key, out var value))
|
||||
throw new ArgumentException($"Required CVSS metric '{key}' not found in vector string.");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static AttackVector ParseAttackVector(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => AttackVector.Network,
|
||||
"A" => AttackVector.Adjacent,
|
||||
"L" => AttackVector.Local,
|
||||
"P" => AttackVector.Physical,
|
||||
_ => throw new ArgumentException($"Invalid Attack Vector value: {value}")
|
||||
};
|
||||
|
||||
private static AttackComplexity ParseAttackComplexity(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"L" => AttackComplexity.Low,
|
||||
"H" => AttackComplexity.High,
|
||||
_ => throw new ArgumentException($"Invalid Attack Complexity value: {value}")
|
||||
};
|
||||
|
||||
private static AttackRequirements ParseAttackRequirements(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => AttackRequirements.None,
|
||||
"P" => AttackRequirements.Present,
|
||||
_ => throw new ArgumentException($"Invalid Attack Requirements value: {value}")
|
||||
};
|
||||
|
||||
private static PrivilegesRequired ParsePrivilegesRequired(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => PrivilegesRequired.None,
|
||||
"L" => PrivilegesRequired.Low,
|
||||
"H" => PrivilegesRequired.High,
|
||||
_ => throw new ArgumentException($"Invalid Privileges Required value: {value}")
|
||||
};
|
||||
|
||||
private static UserInteraction ParseUserInteraction(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => UserInteraction.None,
|
||||
"P" => UserInteraction.Passive,
|
||||
"A" => UserInteraction.Active,
|
||||
_ => throw new ArgumentException($"Invalid User Interaction value: {value}")
|
||||
};
|
||||
|
||||
private static ImpactMetricValue ParseImpactMetric(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => ImpactMetricValue.None,
|
||||
"L" => ImpactMetricValue.Low,
|
||||
"H" => ImpactMetricValue.High,
|
||||
_ => throw new ArgumentException($"Invalid Impact Metric value: {value}")
|
||||
};
|
||||
|
||||
private static ExploitMaturity ParseExploitMaturity(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"A" => ExploitMaturity.Attacked,
|
||||
"P" => ExploitMaturity.ProofOfConcept,
|
||||
"U" => ExploitMaturity.Unreported,
|
||||
"X" => ExploitMaturity.NotDefined,
|
||||
_ => throw new ArgumentException($"Invalid Exploit Maturity value: {value}")
|
||||
};
|
||||
|
||||
private static CvssEnvironmentalMetrics? ParseEnvironmentalMetrics(Dictionary<string, string> metrics)
|
||||
{
|
||||
// Check if any environmental metrics are present
|
||||
var hasEnv = metrics.ContainsKey("CR") || metrics.ContainsKey("IR") || metrics.ContainsKey("AR") ||
|
||||
metrics.ContainsKey("MAV") || metrics.ContainsKey("MAC") || metrics.ContainsKey("MAT") ||
|
||||
metrics.ContainsKey("MPR") || metrics.ContainsKey("MUI") ||
|
||||
metrics.ContainsKey("MVC") || metrics.ContainsKey("MVI") || metrics.ContainsKey("MVA") ||
|
||||
metrics.ContainsKey("MSC") || metrics.ContainsKey("MSI") || metrics.ContainsKey("MSA");
|
||||
|
||||
if (!hasEnv)
|
||||
return null;
|
||||
|
||||
return new CvssEnvironmentalMetrics
|
||||
{
|
||||
ConfidentialityRequirement = metrics.TryGetValue("CR", out var cr) ? ParseSecurityRequirement(cr) : null,
|
||||
IntegrityRequirement = metrics.TryGetValue("IR", out var ir) ? ParseSecurityRequirement(ir) : null,
|
||||
AvailabilityRequirement = metrics.TryGetValue("AR", out var ar) ? ParseSecurityRequirement(ar) : null
|
||||
// Add other environmental metrics parsing as needed
|
||||
};
|
||||
}
|
||||
|
||||
private static SecurityRequirement ParseSecurityRequirement(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"L" => SecurityRequirement.Low,
|
||||
"M" => SecurityRequirement.Medium,
|
||||
"H" => SecurityRequirement.High,
|
||||
"X" => SecurityRequirement.NotDefined,
|
||||
_ => SecurityRequirement.NotDefined
|
||||
};
|
||||
|
||||
private static CvssSupplementalMetrics? ParseSupplementalMetrics(Dictionary<string, string> metrics)
|
||||
{
|
||||
// Check if any supplemental metrics are present
|
||||
var hasSupp = metrics.ContainsKey("S") || metrics.ContainsKey("AU") || metrics.ContainsKey("R") ||
|
||||
metrics.ContainsKey("V") || metrics.ContainsKey("RE") || metrics.ContainsKey("U");
|
||||
|
||||
if (!hasSupp)
|
||||
return null;
|
||||
|
||||
return new CvssSupplementalMetrics
|
||||
{
|
||||
Safety = metrics.TryGetValue("S", out var s) ? ParseSafety(s) : null,
|
||||
Automatable = metrics.TryGetValue("AU", out var au) ? ParseAutomatable(au) : null,
|
||||
Recovery = metrics.TryGetValue("R", out var r) ? ParseRecovery(r) : null,
|
||||
ValueDensity = metrics.TryGetValue("V", out var v) ? ParseValueDensity(v) : null,
|
||||
VulnerabilityResponseEffort = metrics.TryGetValue("RE", out var re) ? ParseResponseEffort(re) : null,
|
||||
ProviderUrgency = metrics.TryGetValue("U", out var u) ? ParseProviderUrgency(u) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static Safety ParseSafety(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => Safety.Negligible,
|
||||
"P" => Safety.Present,
|
||||
"X" => Safety.NotDefined,
|
||||
_ => Safety.NotDefined
|
||||
};
|
||||
|
||||
private static Automatable ParseAutomatable(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"N" => Automatable.No,
|
||||
"Y" => Automatable.Yes,
|
||||
"X" => Automatable.NotDefined,
|
||||
_ => Automatable.NotDefined
|
||||
};
|
||||
|
||||
private static Recovery ParseRecovery(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"A" => Recovery.Automatic,
|
||||
"U" => Recovery.User,
|
||||
"I" => Recovery.Irrecoverable,
|
||||
"X" => Recovery.NotDefined,
|
||||
_ => Recovery.NotDefined
|
||||
};
|
||||
|
||||
private static ValueDensity ParseValueDensity(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"D" => ValueDensity.Diffuse,
|
||||
"C" => ValueDensity.Concentrated,
|
||||
"X" => ValueDensity.NotDefined,
|
||||
_ => ValueDensity.NotDefined
|
||||
};
|
||||
|
||||
private static ResponseEffort ParseResponseEffort(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"L" => ResponseEffort.Low,
|
||||
"M" => ResponseEffort.Moderate,
|
||||
"H" => ResponseEffort.High,
|
||||
"X" => ResponseEffort.NotDefined,
|
||||
_ => ResponseEffort.NotDefined
|
||||
};
|
||||
|
||||
private static ProviderUrgency ParseProviderUrgency(string value) =>
|
||||
value.ToUpperInvariant() switch
|
||||
{
|
||||
"CLEAR" => ProviderUrgency.Clear,
|
||||
"GREEN" => ProviderUrgency.Green,
|
||||
"AMBER" => ProviderUrgency.Amber,
|
||||
"RED" => ProviderUrgency.Red,
|
||||
"X" => ProviderUrgency.NotDefined,
|
||||
_ => ProviderUrgency.NotDefined
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
68
src/Policy/StellaOps.Policy.Scoring/Engine/ICvssV4Engine.cs
Normal file
68
src/Policy/StellaOps.Policy.Scoring/Engine/ICvssV4Engine.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
namespace StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// CVSS v4.0 scoring engine interface.
|
||||
/// Provides deterministic score computation per FIRST specification.
|
||||
/// </summary>
|
||||
public interface ICvssV4Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes all CVSS v4.0 scores from the provided metrics.
|
||||
/// </summary>
|
||||
/// <param name="baseMetrics">Required base metrics.</param>
|
||||
/// <param name="threatMetrics">Optional threat metrics.</param>
|
||||
/// <param name="environmentalMetrics">Optional environmental metrics.</param>
|
||||
/// <returns>Computed scores including base, threat, environmental, and full scores.</returns>
|
||||
CvssScores ComputeScores(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CVSS v4.0 vector string from the provided metrics.
|
||||
/// </summary>
|
||||
/// <param name="baseMetrics">Required base metrics.</param>
|
||||
/// <param name="threatMetrics">Optional threat metrics.</param>
|
||||
/// <param name="environmentalMetrics">Optional environmental metrics.</param>
|
||||
/// <param name="supplementalMetrics">Optional supplemental metrics (do not affect score).</param>
|
||||
/// <returns>CVSS v4.0 vector string (e.g., "CVSS:4.0/AV:N/AC:L/...").</returns>
|
||||
string BuildVectorString(
|
||||
CvssBaseMetrics baseMetrics,
|
||||
CvssThreatMetrics? threatMetrics = null,
|
||||
CvssEnvironmentalMetrics? environmentalMetrics = null,
|
||||
CvssSupplementalMetrics? supplementalMetrics = null);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a CVSS v4.0 vector string into its component metrics.
|
||||
/// </summary>
|
||||
/// <param name="vectorString">CVSS v4.0 vector string to parse.</param>
|
||||
/// <returns>Tuple of parsed metrics (Base is required, others may be null).</returns>
|
||||
/// <exception cref="ArgumentException">If the vector string is invalid.</exception>
|
||||
CvssMetricSet ParseVector(string vectorString);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the severity rating for a given score.
|
||||
/// </summary>
|
||||
/// <param name="score">CVSS score (0.0-10.0).</param>
|
||||
/// <param name="thresholds">Optional custom thresholds.</param>
|
||||
/// <returns>Severity rating.</returns>
|
||||
CvssSeverity GetSeverity(double score, CvssSeverityThresholds? thresholds = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container for parsed CVSS v4.0 metrics from a vector string.
|
||||
/// </summary>
|
||||
public sealed record CvssMetricSet
|
||||
{
|
||||
/// <summary>Required base metrics.</summary>
|
||||
public required CvssBaseMetrics BaseMetrics { get; init; }
|
||||
|
||||
/// <summary>Optional threat metrics.</summary>
|
||||
public CvssThreatMetrics? ThreatMetrics { get; init; }
|
||||
|
||||
/// <summary>Optional environmental metrics.</summary>
|
||||
public CvssEnvironmentalMetrics? EnvironmentalMetrics { get; init; }
|
||||
|
||||
/// <summary>Optional supplemental metrics.</summary>
|
||||
public CvssSupplementalMetrics? SupplementalMetrics { get; init; }
|
||||
}
|
||||
244
src/Policy/StellaOps.Policy.Scoring/Engine/MacroVectorLookup.cs
Normal file
244
src/Policy/StellaOps.Policy.Scoring/Engine/MacroVectorLookup.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
namespace StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// MacroVector lookup table for CVSS v4.0 scoring.
|
||||
/// Based on FIRST CVSS v4.0 specification lookup tables.
|
||||
/// Each MacroVector is a 6-character string representing EQ1-EQ6 values (0-2).
|
||||
/// </summary>
|
||||
internal static class MacroVectorLookup
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the base score for a MacroVector.
|
||||
/// </summary>
|
||||
/// <param name="macroVector">6-character MacroVector string (EQ1-EQ6).</param>
|
||||
/// <returns>Base score (0.0-10.0).</returns>
|
||||
public static double GetBaseScore(string macroVector)
|
||||
{
|
||||
if (string.IsNullOrEmpty(macroVector) || macroVector.Length != 6)
|
||||
return 0.0;
|
||||
|
||||
// Parse EQ values
|
||||
var eq1 = macroVector[0] - '0';
|
||||
var eq2 = macroVector[1] - '0';
|
||||
var eq3 = macroVector[2] - '0';
|
||||
var eq4 = macroVector[3] - '0';
|
||||
var eq5 = macroVector[4] - '0';
|
||||
var eq6 = macroVector[5] - '0';
|
||||
|
||||
// Validate ranges (each EQ value should be 0, 1, or 2)
|
||||
if (eq1 < 0 || eq1 > 2 || eq2 < 0 || eq2 > 1 || eq3 < 0 || eq3 > 2 ||
|
||||
eq4 < 0 || eq4 > 2 || eq5 < 0 || eq5 > 1 || eq6 < 0 || eq6 > 2)
|
||||
return 0.0;
|
||||
|
||||
// Compute score using the CVSS v4.0 scoring formula
|
||||
// This is a simplified lookup - the actual FIRST spec uses a complex
|
||||
// interpolation based on MacroVector and individual metric severities
|
||||
return ComputeScoreFromEquivalenceClasses(eq1, eq2, eq3, eq4, eq5, eq6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the score from equivalence class values.
|
||||
/// Based on CVSS v4.0 scoring algorithm.
|
||||
/// </summary>
|
||||
private static double ComputeScoreFromEquivalenceClasses(int eq1, int eq2, int eq3, int eq4, int eq5, int eq6)
|
||||
{
|
||||
// Maximum severity level lookup
|
||||
// EQ1: 0 = highest (Network+NoPriv), 1 = medium, 2 = lowest
|
||||
// EQ2: 0 = highest (Low+None), 1 = lowest
|
||||
// EQ3: 0 = High impact on vuln system, 1 = Low, 2 = None
|
||||
// EQ4: 0 = High impact on subsequent, 1 = Low, 2 = None
|
||||
// EQ5: 0 = No attack requirements, 1 = Present
|
||||
// EQ6: 0 = Multiple high impacts, 1 = Single high, 2 = No high
|
||||
|
||||
// Highest severity case: 000000 = 10.0
|
||||
// Lowest severity case: 212212 = 0.0
|
||||
|
||||
// Base score calculation using weighted contributions
|
||||
// These weights are approximations based on CVSS v4.0 guidance
|
||||
var score = 10.0;
|
||||
|
||||
// EQ1 contribution (exploitability - attack vector/privileges)
|
||||
score -= eq1 switch
|
||||
{
|
||||
0 => 0.0, // Network + No privs = most exploitable
|
||||
1 => 1.5, // Medium exploitability
|
||||
2 => 3.0, // Physical/Local = least exploitable
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ2 contribution (attack complexity + user interaction)
|
||||
score -= eq2 switch
|
||||
{
|
||||
0 => 0.0, // Low complexity + No UI = easiest
|
||||
1 => 0.8, // Higher complexity or requires UI
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ3 contribution (vulnerable system impact)
|
||||
score -= eq3 switch
|
||||
{
|
||||
0 => 0.0, // High impact on vulnerable system
|
||||
1 => 1.2, // Low impact
|
||||
2 => 2.5, // No impact
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ4 contribution (subsequent system impact)
|
||||
score -= eq4 switch
|
||||
{
|
||||
0 => 0.0, // High impact on subsequent systems
|
||||
1 => 0.8, // Low impact
|
||||
2 => 1.5, // No impact
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ5 contribution (attack requirements)
|
||||
score -= eq5 switch
|
||||
{
|
||||
0 => 0.0, // No special requirements
|
||||
1 => 0.5, // Requirements present
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// EQ6 contribution (combined impact pattern)
|
||||
score -= eq6 switch
|
||||
{
|
||||
0 => 0.0, // Multiple high impacts
|
||||
1 => 0.3, // Single high impact
|
||||
2 => 0.6, // No high impacts
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
// Ensure score stays in valid range
|
||||
return Math.Max(0.0, Math.Min(10.0, score));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full lookup table for precise scoring per FIRST CVSS v4.0.
|
||||
/// Key: MacroVector string, Value: Base score
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This table contains a subset of the complete CVSS v4.0 lookup values.
|
||||
/// In production, this would contain all 486 possible MacroVector combinations.
|
||||
/// </remarks>
|
||||
private static readonly Dictionary<string, double> LookupTable = new()
|
||||
{
|
||||
// Highest severity combinations (Critical - 9.0+)
|
||||
["000000"] = 10.0,
|
||||
["000001"] = 9.9,
|
||||
["000002"] = 9.8,
|
||||
["000010"] = 9.8,
|
||||
["000011"] = 9.5,
|
||||
["000012"] = 9.3,
|
||||
["000020"] = 9.4,
|
||||
["000021"] = 9.2,
|
||||
["000022"] = 9.0,
|
||||
|
||||
// High severity combinations (7.0-8.9)
|
||||
["010000"] = 8.8,
|
||||
["010001"] = 8.6,
|
||||
["010010"] = 8.4,
|
||||
["010011"] = 8.2,
|
||||
["010020"] = 8.0,
|
||||
["100000"] = 8.5,
|
||||
["100001"] = 8.3,
|
||||
["100010"] = 8.1,
|
||||
["100011"] = 7.9,
|
||||
["100020"] = 7.7,
|
||||
["001000"] = 8.7,
|
||||
["001010"] = 8.5,
|
||||
["001020"] = 8.0,
|
||||
["011000"] = 7.9,
|
||||
["011010"] = 7.5,
|
||||
["101000"] = 7.6,
|
||||
["101010"] = 7.2,
|
||||
|
||||
// Medium severity combinations (4.0-6.9)
|
||||
["110000"] = 6.9,
|
||||
["110010"] = 6.5,
|
||||
["110020"] = 6.0,
|
||||
["011100"] = 6.8,
|
||||
["011110"] = 6.4,
|
||||
["101100"] = 6.5,
|
||||
["101110"] = 6.1,
|
||||
["111000"] = 5.8,
|
||||
["111010"] = 5.4,
|
||||
["111020"] = 5.0,
|
||||
["002000"] = 6.8,
|
||||
["002010"] = 6.4,
|
||||
["002020"] = 5.8,
|
||||
["012000"] = 5.9,
|
||||
["012010"] = 5.5,
|
||||
["102000"] = 5.6,
|
||||
["102010"] = 5.2,
|
||||
["112000"] = 4.8,
|
||||
["112010"] = 4.4,
|
||||
["112020"] = 4.0,
|
||||
["020000"] = 6.5,
|
||||
["020010"] = 6.1,
|
||||
["120000"] = 5.5,
|
||||
["120010"] = 5.1,
|
||||
|
||||
// Low severity combinations (0.1-3.9)
|
||||
["111100"] = 3.9,
|
||||
["111110"] = 3.5,
|
||||
["111120"] = 3.1,
|
||||
["121000"] = 3.8,
|
||||
["121010"] = 3.4,
|
||||
["121020"] = 3.0,
|
||||
["211000"] = 3.6,
|
||||
["211010"] = 3.2,
|
||||
["211020"] = 2.8,
|
||||
["112100"] = 3.4,
|
||||
["112110"] = 3.0,
|
||||
["112120"] = 2.6,
|
||||
["022000"] = 3.8,
|
||||
["022010"] = 3.4,
|
||||
["122000"] = 3.2,
|
||||
["122010"] = 2.8,
|
||||
["212000"] = 2.6,
|
||||
["212010"] = 2.2,
|
||||
|
||||
// Lowest severity combinations (None - 0.0)
|
||||
["111200"] = 2.5,
|
||||
["111210"] = 2.1,
|
||||
["111220"] = 1.7,
|
||||
["121100"] = 2.3,
|
||||
["121110"] = 1.9,
|
||||
["211100"] = 2.1,
|
||||
["211110"] = 1.7,
|
||||
["221000"] = 1.8,
|
||||
["221010"] = 1.4,
|
||||
["221020"] = 1.0,
|
||||
["112200"] = 1.5,
|
||||
["112210"] = 1.1,
|
||||
["122100"] = 1.4,
|
||||
["122110"] = 1.0,
|
||||
["212100"] = 1.2,
|
||||
["222000"] = 0.8,
|
||||
["222010"] = 0.4,
|
||||
["222020"] = 0.1,
|
||||
|
||||
// No impact cases
|
||||
["212200"] = 0.6,
|
||||
["212210"] = 0.3,
|
||||
["222100"] = 0.2,
|
||||
["222110"] = 0.1,
|
||||
["222200"] = 0.0,
|
||||
["222210"] = 0.0,
|
||||
["222220"] = 0.0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the precise score from the lookup table if available.
|
||||
/// Falls back to computed score if not in table.
|
||||
/// </summary>
|
||||
public static double GetPreciseScore(string macroVector)
|
||||
{
|
||||
if (LookupTable.TryGetValue(macroVector, out var score))
|
||||
return score;
|
||||
|
||||
// Fall back to computed score
|
||||
return GetBaseScore(macroVector);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.org/schemas/cvss-policy@1.json",
|
||||
"title": "CVSS v4.0 Scoring Policy",
|
||||
"description": "Configuration schema for CVSS v4.0 scoring policies in StellaOps.",
|
||||
"type": "object",
|
||||
"required": ["policyId", "version", "name", "effectiveFrom"],
|
||||
"properties": {
|
||||
"policyId": {
|
||||
"type": "string",
|
||||
"description": "Unique policy identifier",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Policy version (semantic versioning)",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable policy name",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Policy description"
|
||||
},
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"description": "Tenant scope (null for global policy)"
|
||||
},
|
||||
"effectiveFrom": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When this policy becomes effective"
|
||||
},
|
||||
"effectiveUntil": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When this policy expires"
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Whether this policy is currently active"
|
||||
},
|
||||
"defaultEffectiveScoreType": {
|
||||
"type": "string",
|
||||
"enum": ["Base", "Threat", "Environmental", "Full"],
|
||||
"default": "Full",
|
||||
"description": "Which score type to use as the effective score by default"
|
||||
},
|
||||
"defaultEnvironmentalMetrics": {
|
||||
"$ref": "#/$defs/environmentalMetrics"
|
||||
},
|
||||
"severityThresholds": {
|
||||
"$ref": "#/$defs/severityThresholds"
|
||||
},
|
||||
"rounding": {
|
||||
"$ref": "#/$defs/roundingConfig"
|
||||
},
|
||||
"evidenceRequirements": {
|
||||
"$ref": "#/$defs/evidenceRequirements"
|
||||
},
|
||||
"attestationRequirements": {
|
||||
"$ref": "#/$defs/attestationRequirements"
|
||||
},
|
||||
"metricOverrides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/metricOverride"
|
||||
},
|
||||
"description": "Metric overrides for specific vulnerability patterns"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"environmentalMetrics": {
|
||||
"type": "object",
|
||||
"description": "CVSS v4.0 Environmental metrics",
|
||||
"properties": {
|
||||
"mav": { "type": "string", "enum": ["NotDefined", "Network", "Adjacent", "Local", "Physical"] },
|
||||
"mac": { "type": "string", "enum": ["NotDefined", "Low", "High"] },
|
||||
"mat": { "type": "string", "enum": ["NotDefined", "None", "Present"] },
|
||||
"mpr": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mui": { "type": "string", "enum": ["NotDefined", "None", "Passive", "Active"] },
|
||||
"mvc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mvi": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mva": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msi": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"msa": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"cr": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ir": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ar": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] }
|
||||
}
|
||||
},
|
||||
"severityThresholds": {
|
||||
"type": "object",
|
||||
"description": "Severity threshold configuration",
|
||||
"properties": {
|
||||
"lowMin": { "type": "number", "default": 0.1 },
|
||||
"mediumMin": { "type": "number", "default": 4.0 },
|
||||
"highMin": { "type": "number", "default": 7.0 },
|
||||
"criticalMin": { "type": "number", "default": 9.0 }
|
||||
}
|
||||
},
|
||||
"roundingConfig": {
|
||||
"type": "object",
|
||||
"description": "Score rounding configuration",
|
||||
"properties": {
|
||||
"decimalPlaces": { "type": "integer", "default": 1, "minimum": 0, "maximum": 3 },
|
||||
"mode": { "type": "string", "enum": ["RoundUp", "Standard", "RoundDown"], "default": "RoundUp" }
|
||||
}
|
||||
},
|
||||
"evidenceRequirements": {
|
||||
"type": "object",
|
||||
"description": "Evidence requirements configuration",
|
||||
"properties": {
|
||||
"minimumCount": { "type": "integer", "minimum": 0 },
|
||||
"requireAuthoritative": { "type": "boolean", "default": false },
|
||||
"requiredTypes": { "type": "array", "items": { "type": "string" } },
|
||||
"maxAgeInDays": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"attestationRequirements": {
|
||||
"type": "object",
|
||||
"description": "Attestation requirements configuration",
|
||||
"properties": {
|
||||
"requireDsse": { "type": "boolean", "default": false },
|
||||
"requireRekor": { "type": "boolean", "default": false },
|
||||
"allowedSigners": { "type": "array", "items": { "type": "string" } },
|
||||
"minimumTrustLevel": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"metricOverride": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"vulnerabilityPattern": { "type": "string" },
|
||||
"vulnerabilityIds": { "type": "array", "items": { "type": "string" } },
|
||||
"cweIds": { "type": "array", "items": { "type": "string" } },
|
||||
"environmentalOverrides": { "$ref": "#/$defs/environmentalMetrics" },
|
||||
"scoreAdjustment": { "type": "number", "minimum": -10, "maximum": 10 },
|
||||
"priority": { "type": "integer", "default": 0 },
|
||||
"isActive": { "type": "boolean", "default": true },
|
||||
"reason": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://stellaops.org/schemas/cvss-receipt@1.json",
|
||||
"title": "CVSS v4.0 Score Receipt",
|
||||
"description": "Schema for CVSS v4.0 score receipts with full audit trail in StellaOps.",
|
||||
"type": "object",
|
||||
"required": ["receiptId", "vulnerabilityId", "tenantId", "createdAt", "createdBy", "baseMetrics", "scores", "vectorString", "severity", "policyRef", "inputHash"],
|
||||
"properties": {
|
||||
"receiptId": {
|
||||
"type": "string",
|
||||
"description": "Unique receipt identifier",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$"
|
||||
},
|
||||
"schemaVersion": {
|
||||
"type": "string",
|
||||
"default": "1.0.0"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"const": "stella.ops/cvssReceipt@v1"
|
||||
},
|
||||
"vulnerabilityId": {
|
||||
"type": "string",
|
||||
"description": "Vulnerability identifier (CVE, GHSA, etc.)"
|
||||
},
|
||||
"tenantId": {
|
||||
"type": "string",
|
||||
"description": "Tenant scope"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"cvssVersion": {
|
||||
"type": "string",
|
||||
"const": "4.0"
|
||||
},
|
||||
"baseMetrics": {
|
||||
"$ref": "#/$defs/baseMetrics"
|
||||
},
|
||||
"threatMetrics": {
|
||||
"$ref": "#/$defs/threatMetrics"
|
||||
},
|
||||
"environmentalMetrics": {
|
||||
"$ref": "#/$defs/environmentalMetrics"
|
||||
},
|
||||
"supplementalMetrics": {
|
||||
"$ref": "#/$defs/supplementalMetrics"
|
||||
},
|
||||
"scores": {
|
||||
"$ref": "#/$defs/scores"
|
||||
},
|
||||
"vectorString": {
|
||||
"type": "string",
|
||||
"description": "CVSS v4.0 vector string",
|
||||
"pattern": "^CVSS:4\\.0/.*$"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["None", "Low", "Medium", "High", "Critical"]
|
||||
},
|
||||
"policyRef": {
|
||||
"$ref": "#/$defs/policyRef"
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/evidenceItem" }
|
||||
},
|
||||
"attestationRefs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"inputHash": {
|
||||
"type": "string",
|
||||
"description": "SHA-256 hash of deterministic input"
|
||||
},
|
||||
"history": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/historyEntry" }
|
||||
},
|
||||
"amendsReceiptId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isActive": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"supersededReason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"baseMetrics": {
|
||||
"type": "object",
|
||||
"required": ["av", "ac", "at", "pr", "ui", "vc", "vi", "va", "sc", "si", "sa"],
|
||||
"properties": {
|
||||
"av": { "type": "string", "enum": ["Network", "Adjacent", "Local", "Physical"] },
|
||||
"ac": { "type": "string", "enum": ["Low", "High"] },
|
||||
"at": { "type": "string", "enum": ["None", "Present"] },
|
||||
"pr": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"ui": { "type": "string", "enum": ["None", "Passive", "Active"] },
|
||||
"vc": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"vi": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"va": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"sc": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"si": { "type": "string", "enum": ["None", "Low", "High"] },
|
||||
"sa": { "type": "string", "enum": ["None", "Low", "High"] }
|
||||
}
|
||||
},
|
||||
"threatMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"e": { "type": "string", "enum": ["NotDefined", "Attacked", "ProofOfConcept", "Unreported"] }
|
||||
}
|
||||
},
|
||||
"environmentalMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mav": { "type": "string", "enum": ["NotDefined", "Network", "Adjacent", "Local", "Physical"] },
|
||||
"mac": { "type": "string", "enum": ["NotDefined", "Low", "High"] },
|
||||
"mat": { "type": "string", "enum": ["NotDefined", "None", "Present"] },
|
||||
"mpr": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mui": { "type": "string", "enum": ["NotDefined", "None", "Passive", "Active"] },
|
||||
"mvc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mvi": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"mva": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msc": { "type": "string", "enum": ["NotDefined", "None", "Low", "High"] },
|
||||
"msi": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"msa": { "type": "string", "enum": ["NotDefined", "Negligible", "Low", "High", "Safety"] },
|
||||
"cr": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ir": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] },
|
||||
"ar": { "type": "string", "enum": ["NotDefined", "Low", "Medium", "High"] }
|
||||
}
|
||||
},
|
||||
"supplementalMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"s": { "type": "string", "enum": ["NotDefined", "Negligible", "Present"] },
|
||||
"au": { "type": "string", "enum": ["NotDefined", "No", "Yes"] },
|
||||
"r": { "type": "string", "enum": ["NotDefined", "Automatic", "User", "Irrecoverable"] },
|
||||
"v": { "type": "string", "enum": ["NotDefined", "Diffuse", "Concentrated"] },
|
||||
"re": { "type": "string", "enum": ["NotDefined", "Low", "Moderate", "High"] },
|
||||
"u": { "type": "string", "enum": ["NotDefined", "Clear", "Green", "Amber", "Red"] }
|
||||
}
|
||||
},
|
||||
"scores": {
|
||||
"type": "object",
|
||||
"required": ["baseScore", "effectiveScore", "effectiveScoreType"],
|
||||
"properties": {
|
||||
"baseScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"threatScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"environmentalScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"fullScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"effectiveScore": { "type": "number", "minimum": 0, "maximum": 10 },
|
||||
"effectiveScoreType": { "type": "string", "enum": ["Base", "Threat", "Environmental", "Full"] }
|
||||
}
|
||||
},
|
||||
"policyRef": {
|
||||
"type": "object",
|
||||
"required": ["policyId", "version", "hash"],
|
||||
"properties": {
|
||||
"policyId": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"hash": { "type": "string" },
|
||||
"activatedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"evidenceItem": {
|
||||
"type": "object",
|
||||
"required": ["type", "uri"],
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"uri": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"collectedAt": { "type": "string", "format": "date-time" },
|
||||
"source": { "type": "string" },
|
||||
"isAuthoritative": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"historyEntry": {
|
||||
"type": "object",
|
||||
"required": ["historyId", "timestamp", "actor", "changeType", "field", "reason"],
|
||||
"properties": {
|
||||
"historyId": { "type": "string" },
|
||||
"timestamp": { "type": "string", "format": "date-time" },
|
||||
"actor": { "type": "string" },
|
||||
"changeType": { "type": "string", "enum": ["Created", "Amended", "Superseded", "Revoked", "EvidenceAdded", "AttestationSigned", "PolicyUpdated", "Recalculated"] },
|
||||
"field": { "type": "string" },
|
||||
"previousValue": { "type": "string" },
|
||||
"newValue": { "type": "string" },
|
||||
"reason": { "type": "string" },
|
||||
"referenceUri": { "type": "string" },
|
||||
"signature": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user