diff --git a/docs/implplan/SPRINT_20260422_004_Concelier_full_connector_control_plane.md b/docs/implplan/SPRINT_20260422_004_Concelier_full_connector_control_plane.md index 50bc8deed..650008da7 100644 --- a/docs/implplan/SPRINT_20260422_004_Concelier_full_connector_control_plane.md +++ b/docs/implplan/SPRINT_20260422_004_Concelier_full_connector_control_plane.md @@ -24,7 +24,7 @@ ## Delivery Tracker ### CONN-CTRL-01 - Excititor provider management backend -Status: DOING +Status: DONE Dependency: none Owners: Developer / Implementer Task description: @@ -32,12 +32,12 @@ Task description: - The backend must report truthful status for built-in and persisted providers using shared readiness concepts (`ready`, `blocked`, `disabled`, `planned`) and must not create a second source of truth outside the existing Excititor persistence layer. Completion criteria: -- [ ] Excititor WebService exposes provider catalog and status endpoints backed by persisted provider state. -- [ ] Enable, disable, and run actions exist for provider entries and preserve truthful blocked states when configuration is incomplete. -- [ ] Targeted backend tests cover the new provider control-plane behavior. +- [x] Excititor WebService exposes provider catalog and status endpoints backed by persisted provider state. +- [x] Enable, disable, and run actions exist for provider entries and preserve truthful blocked states when configuration is incomplete. +- [x] Targeted backend tests cover the new provider control-plane behavior. ### CONN-CTRL-02 - CLI and Web control plane wiring -Status: TODO +Status: DONE Dependency: CONN-CTRL-01 Owners: Developer / Implementer Task description: @@ -45,12 +45,12 @@ Task description: - Keep the VEX provider surface separate from advisory source management, but link the navigation and terminology so operators can move between both views. Completion criteria: -- [ ] CLI exposes Excititor provider inspection and control actions against the new backend API. -- [ ] Web UI exposes a linked VEX provider management view with truthful readiness and operator actions. -- [ ] Existing advisory source flows remain intact. +- [x] CLI exposes Excititor provider inspection and control actions against the new backend API. +- [x] Web UI exposes a linked VEX provider management view with truthful readiness and operator actions. +- [x] Existing advisory source flows remain intact. ### CONN-CTRL-03 - Connector inventory docs and operator guidance -Status: TODO +Status: DONE Dependency: CONN-CTRL-02 Owners: Documentation author / Developer / Implementer Task description: @@ -58,20 +58,25 @@ Task description: - Record implementation decisions, evidence, and any remaining catalog-to-runtime gaps. Completion criteria: -- [ ] Docs under `docs/modules/concelier/` and `docs/modules/excititor/` describe inventory, readiness semantics, and operator workflows. -- [ ] Sprint `Execution Log` and `Decisions & Risks` link to the updated docs and test evidence. -- [ ] Any remaining unsupported connectors are documented as planned rather than implied to be working. +- [x] Docs under `docs/modules/concelier/` and `docs/modules/excititor/` describe inventory, readiness semantics, and operator workflows. +- [x] Sprint `Execution Log` and `Decisions & Risks` link to the updated docs and test evidence. +- [x] Any remaining unsupported connectors are documented as planned rather than implied to be working. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-04-22 | Sprint created. Began Excititor provider control-plane implementation using the persisted provider store and connector state repositories as the single runtime truth. | Developer | +| 2026-04-22 | Added `/excititor/providers` list, detail, update, enable, disable, and run endpoints plus targeted provider-management tests. `scripts/test-targeted-xunit.ps1` passed for `StellaOps.Excititor.WebService.Tests.ProviderManagementEndpointsTests` with 5 tests run and 0 failures. | Developer | +| 2026-04-22 | Wired CLI provider verbs and the linked Web VEX provider management page. `npx vitest run --config vitest.codex.config.ts src/app/features/integrations/advisory-vex-sources/advisory-vex-route-helpers.spec.ts` passed and `npx ng build --configuration development` passed. | Developer | +| 2026-04-22 | Updated connector inventory and Excititor provider control-plane docs to reflect the 78-source advisory catalog, 31 built-in runnable advisory pipelines, seven VEX provider IDs, and the remaining host-config-only credential gaps on the VEX side. | Developer | ## Decisions & Risks - Decision: advisory sources and VEX providers remain separate operator surfaces, but they will share readiness language and linked navigation so the control plane stays truthful without flattening different runtimes into one fake catalog. - Decision: credential-gated connectors must preserve operator enable intent while reporting `blocked` readiness until stored configuration is supplied through supported UI or CLI paths. +- Decision: the advisory catalog inventory is now documented from source-of-truth registries instead of stale module summaries. See `docs/modules/concelier/connectors.md` and `docs/modules/excititor/operations/provider-control-plane.md`. - Risk: the Concelier advisory catalog is broader than the current runnable host surface, and Excititor currently has worker-seeded defaults but no WebService control plane. Docs and APIs must distinguish catalog breadth from tested runtime support to avoid false claims. - Risk: Web routing already contains advisory-oriented feature naming. Adding VEX management without duplicating large UI components may require targeted refactoring in `src/Web/`. +- Risk: Excititor provider metadata can now be managed via UI and CLI, but secret-bearing connector settings for `excititor:msrc`, `excititor:suse-rancher`, `excititor:oci-openvex`, and optional Cisco VEX auth remain host-config-only. The provider control plane surfaces this truth as `planned` or `blocked` instead of claiming end-to-end credential parity that does not yet exist. ## Next Checkpoints - Backend provider endpoints and tests passing. diff --git a/docs/modules/concelier/README.md b/docs/modules/concelier/README.md index 333dcc92f..5f7cd4bfc 100644 --- a/docs/modules/concelier/README.md +++ b/docs/modules/concelier/README.md @@ -1,15 +1,12 @@ # StellaOps Concelier -Concelier ingests signed advisories from **32 advisory connectors** and converts them into immutable observations plus linksets under the Aggregation-Only Contract (AOC). +Concelier maintains a catalog of **78 advisory source definitions** and currently wires **31 built-in runnable advisory pipelines** in the default WebService host. It converts signed advisories into immutable observations plus linksets under the Aggregation-Only Contract (AOC). -**Advisory Sources (32 connectors):** -- **National CERTs (8):** ACSC (Australia), CCCS (Canada), CERT-Bund (Germany), CERT-CC (US), CERT-FR (France), CERT-IN (India), JVN (Japan), KISA (Korea) -- **OS Distros (5):** Alpine SecDB, Debian Security Tracker, RedHat OVAL, SUSE OVAL, Ubuntu USN -- **Vendors (7):** Apple, Adobe, Chromium, Cisco PSIRT, Microsoft MSRC, Oracle, VMware -- **Standards (5):** CVE, NVD, GHSA (GitHub), OSV, EPSS v4 -- **Threat Intel (3):** KEV (CISA Exploited Vulns), CISA ICS, Kaspersky ICS -- **Regional (3):** Russia BDU, Russia NKCKI, Plus regional mirrors -- **Internal (1):** StellaOps internal mirror +Current operator references: + +- Full advisory inventory and runnable-vs-catalog truth: [`connectors.md`](./connectors.md) +- Stored credential and endpoint override entry paths: [`operations/source-credentials.md`](./operations/source-credentials.md) +- Per-connector runbooks: `./operations/connectors/` ## Responsibilities - Fetch and normalise vulnerability advisories via restart-time connectors. diff --git a/docs/modules/concelier/connectors.md b/docs/modules/concelier/connectors.md index d0ed565cf..17460a0d4 100644 --- a/docs/modules/concelier/connectors.md +++ b/docs/modules/concelier/connectors.md @@ -1,265 +1,166 @@ # Concelier Connectors -This index lists Concelier connectors, their status, authentication expectations, and links to operational runbooks. For procedures and alerting, see `docs/modules/concelier/operations/connectors/`. +This index is the authoritative operator-facing inventory for the Concelier advisory source catalog and the linked Excititor VEX provider control plane. -Operator configuration note: +## Current control-plane counts -- Supported advisory source credentials and endpoint overrides can now be supplied through the Web UI or `stella db connectors configure ...`. -- GHSA, Cisco, and Microsoft use operator-supplied credentials through that path. -- Oracle, Adobe, and Chromium use public defaults and only need UI or CLI input when you override or mirror the upstream endpoints. -- See [source-credentials.md](docs/modules/concelier/operations/source-credentials.md). +- Advisory source catalog definitions: `78` +- Advisory sources with built-in runnable fetch pipelines on this host: `31` +- Advisory sources with stored connector configuration exposed through both Web UI and CLI: `6` +- Excititor VEX providers in the provider catalog: `7` ---- +Operator entry points: -## Blocked / sleeping readiness state +- Advisory source catalog: `Ops -> Integrations -> Advisory & VEX Sources` +- Advisory source stored configuration: source card -> `Stored Connector Configuration` +- Advisory source CLI path: `stella db connectors configure ` +- VEX provider catalog: `Ops -> Integrations -> Advisory & VEX Sources -> VEX Providers` +- VEX provider CLI path: `stella excititor list-providers`, `show-provider`, `enable-provider`, `disable-provider`, `run-provider`, `update-provider` -Each advisory source has two independent flags in its status response: +Related docs: -| Field | Meaning | -| --- | --- | -| `enabled` | Persisted operator intent. `true` means "the operator asked for this source to run". Survives restarts, backfills, and connectivity checks. | -| `readiness` | Runtime readiness. One of `ready`, `blocked`, `disabled`, or `unsupported`. Computed live from connector configuration. | +- Stored advisory credentials and endpoint overrides: `docs/modules/concelier/operations/source-credentials.md` +- Excititor provider control plane: `docs/modules/excititor/operations/provider-control-plane.md` +- Connector runbooks: `docs/modules/concelier/operations/connectors/` -The `blocked` state is reserved for **credential-gated or URI-gated sources that are persisted-enabled but missing required configuration**. In this state: +## Readiness model -- `enabled` remains `true` — the operator's intent is preserved across restarts. -- `readiness` (alias `syncState`) is `blocked`. -- `blockedReason` is a free-form human-readable message naming the missing field(s) (for example, `"GitHub Security Advisories requires an API token before sync can run."`). -- `blockingReason` carries the structured diagnostics object: `errorCode = SOURCE_CONFIG_REQUIRED`, `possibleReasons`, and ordered `remediationSteps`. -- The scheduler and the manual `/sync` and `/sync-all` endpoints **short-circuit** — the connector is never invoked, so the operator does not see a generic scheduler failure or a misleading "last run succeeded" state. +Advisory sources and VEX providers preserve operator intent separately from runtime readiness. -### Endpoint-by-endpoint contract +Advisory sources return: -| Endpoint | Blocked behaviour | -| --- | --- | -| `GET /api/v1/advisory-sources/status` | Per-source `readiness = "blocked"`, `blockedReason` populated, `readyForSync = false`, `enabled = true`. | -| `POST /api/v1/advisory-sources/{sourceId}/enable` | Returns `200 OK` with `{ enabled: true, readiness: "blocked", blockingReason, blockedReason }`. The persisted row is enabled but the source registry is left disabled until credentials land. | -| `POST /api/v1/advisory-sources/{sourceId}/sync` | Returns `422 Unprocessable Entity` with `{ error: "source_config_required", readiness: "blocked", code: "SOURCE_CONFIG_REQUIRED", blockedReason }`. The connector is **not** invoked and no job run is created. | -| `POST /api/v1/advisory-sources/sync` | Each blocked source is reported inside `results[]` with `outcome: "blocked"`, `readiness: "blocked"`, `errorCode: "SOURCE_CONFIG_REQUIRED"`, `blockedReason`; it is excluded from `totalTriggered`. Other sources in the batch still run normally. | -| `POST /api/v1/advisory-sources/check` | Blocked sources keep their persisted `enabled` value instead of being auto-disabled by the periodic connectivity check, so the status continues to reflect operator intent until credentials are supplied. | +- `enabled`: persisted operator intent +- `readiness` and `syncState`: one of `ready`, `blocked`, `disabled`, or `unsupported` -### Resolving the blocked state +Excititor VEX providers return: -The operator resolves a blocked source by supplying the missing configuration through either entry path: +- `enabled`: persisted operator intent +- `readiness` and `syncState`: one of `ready`, `blocked`, `disabled`, or `planned` -- Web UI: `Integrations -> Advisory sources`, open the source card, fill in the fields under **Configuration**, save. -- CLI: `stella db connectors configure --set =` (see [`docs/modules/cli/guides/commands/db.md`](/C:/dev/New%20folder/git.stella-ops.org/docs/modules/cli/guides/commands/db.md)). +Interpretation: -On the next `/status` call the source's `readiness` flips to `ready`, `blockedReason` becomes `null`, and `readyForSync` becomes `true`. No disable/re-enable dance is required — the runtime settings cache picks up the persisted change through the options-invalidator and the connector runs on the next scheduler tick or manual trigger. +- `blocked` means the operator wants the connector enabled, but the runtime is intentionally holding it until required configuration or retry cooldown conditions clear. +- `unsupported` means the advisory source exists in the catalog but this host does not register a runnable `source::fetch` pipeline. +- `planned` means the VEX provider exists in the provider catalog but the current Excititor host has not registered a runnable connector for it. -UI/CLI rendering guidance: +Canonical runtime note: -- Render `enabled` and `readiness` as two separate indicators. An `enabled` toggle that silently collapses to a "disabled" or "failed" visualisation hides operator intent from the next operator on shift. -- Prefer `blockedReason` (short human sentence) for the visible row and fall back to `blockingReason.possibleReasons` / `remediationSteps` for an expanded drawer. -- Do not treat `blocked` as an error state for alerting purposes — it is an expected "sleeping" state on fresh installs and on hosts that have not yet received the credential set. +- Advisory source IDs come from `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs` +- Advisory source aliases are normalized by `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceKeyAliases.cs` +- Advisory source runnable pipelines come from `src/Concelier/StellaOps.Concelier.WebService/Extensions/JobRegistrationExtensions.cs` +- Excititor provider readiness comes from `src/Concelier/StellaOps.Excititor.WebService/Services/VexProviderManagementService.cs` -The catalog currently contains **78 source definitions** across **14 categories**. The authoritative source list is defined in `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs`. +## Advisory source inventory -Canonical runtime note: the operator-facing source IDs in this index are the only scheduler/catalog IDs that should be used for Concelier jobs and setup. Legacy connector aliases such as `ics-cisa`, `ics-kaspersky`, `ru-bdu`, `ru-nkcki`, `vndr-adobe`, `vndr-apple`, `vndr-chromium`, `vndr-cisco`, `vndr-oracle`, and `vndr.msrc` remain compatibility-only aliases inside normalization paths and must not appear as primary runtime job keys. +Legend: -Runtime note: the Concelier advisory catalog and the Excititor default VEX mirror bootstrap share some upstream vendors but are not the same pipeline. The default public VEX bootstrap currently seeds only `redhat`, `ubuntu`, `oracle`, and `cisco`, uses their public CSAF/notice endpoints, and staggers initial runs (`5m`, `7m`, `9m`, `11m`) to avoid burst-fetching multiple upstreams at the same instant. +- `Built-in runnable = yes` means this Concelier WebService registers a `source::fetch` job. +- `Stored config = UI+CLI` means operators can persist credentials or endpoint overrides through both the Web UI and `stella db connectors configure`. +- `Stored config = none` means no persisted connector-specific source configuration schema exists today on the advisory side. ---- - -## Source categories - -| Category | Description | Source count | -| --- | --- | --- | -| Primary | Core vulnerability databases (NVD, OSV, GHSA, CVE) | 4 | -| Threat | Threat intelligence, exploit prediction, and known-exploited (EPSS, KEV, MITRE ATT&CK, D3FEND) | 4 | -| Vendor | Vendor PSIRTs and cloud provider security bulletins | 16 | -| Distribution | Linux distribution security trackers | 10 | -| Ecosystem | Language-ecosystem advisory feeds via OSV/GHSA | 9 | -| PackageManager | Native package manager advisory databases (cargo-audit, pip-audit, govulncheck, bundler-audit) | 4 | -| Csaf | CSAF/VEX structured document sources | 3 | -| Exploit | Exploit databases and proof-of-concept repositories | 3 | -| Container | Container image advisory sources | 2 | -| Hardware | Hardware and firmware PSIRT advisories | 3 | -| Ics | Industrial control systems and SCADA advisories | 2 | -| Cert | National CERTs and government CSIRTs | 15 | -| Mirror | StellaOps pre-aggregated mirrors | 1 | -| Other | Uncategorized sources | 0 | - ---- - -## Primary Databases - -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| NVD (NIST) | `nvd` | stable | api-key (optional) | 10 | [nvd.md](docs/modules/concelier/operations/connectors/nvd.md) | -| OSV (Google) | `osv` | stable | none | 15 | [osv.md](docs/modules/concelier/operations/connectors/osv.md) | -| GitHub Security Advisories | `ghsa` | stable | api-token | 20 | [ghsa.md](docs/modules/concelier/operations/connectors/ghsa.md) | -| CVE.org (MITRE) | `cve` | stable | none | 5 | [cve.md](docs/modules/concelier/operations/connectors/cve.md) | - -## Threat Intelligence & Exploit Scoring - -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| EPSS (FIRST) | `epss` | stable | none | 50 | [epss.md](docs/modules/concelier/operations/connectors/epss.md) | -| CISA KEV | `kev` | stable | none | 25 | [cve-kev.md](docs/modules/concelier/operations/connectors/cve-kev.md) | -| MITRE ATT&CK | `mitre-attack` | stable | none | 140 | -- | -| MITRE D3FEND | `mitre-d3fend` | stable | none | 142 | -- | - -MITRE ATT&CK provides adversary tactics and techniques in STIX format from the `mitre/cti` GitHub repository. D3FEND provides the complementary defensive techniques knowledge base. Both are tagged `threat-intel` and consumed via the `SourceType.Upstream` connector. For future STIX/TAXII protocol feeds, the `SourceType.StixTaxii` enum value is available for connector extensibility. - -## Vendor Advisories - -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| Red Hat Security | `redhat` | stable | none | 30 | [redhat.md](docs/modules/concelier/operations/connectors/redhat.md) | -| Microsoft Security (MSRC) | `microsoft` | stable | oauth | 35 | [msrc.md](docs/modules/concelier/operations/connectors/msrc.md) | -| Amazon Linux Security | `amazon` | stable | none | 40 | -- | -| Google Security | `google` | stable | none | 45 | -- | -| Oracle Security | `oracle` | stable | none | 50 | [oracle.md](docs/modules/concelier/operations/connectors/oracle.md) | -| Adobe Security | `adobe` | stable | none | 52 | [adobe.md](docs/modules/concelier/operations/connectors/adobe.md) | -| Apple Security | `apple` | stable | none | 55 | [apple.md](docs/modules/concelier/operations/connectors/apple.md) | -| Chromium Stable Channel Updates | `chromium` | stable | none | 57 | [chromium.md](docs/modules/concelier/operations/connectors/chromium.md) | -| Cisco Security | `cisco` | stable | oauth | 60 | [cisco.md](docs/modules/concelier/operations/connectors/cisco.md) | -| Fortinet PSIRT | `fortinet` | stable | none | 65 | -- | -| Juniper Security | `juniper` | stable | none | 70 | -- | -| Palo Alto Security | `paloalto` | stable | none | 75 | -- | -| VMware Security | `vmware` | stable | none | 80 | [vmware.md](docs/modules/concelier/operations/connectors/vmware.md) | -| AWS Security Bulletins | `aws` | stable | none | 81 | -- | -| Azure Security Advisories | `azure` | stable | none | 82 | -- | -| GCP Security Bulletins | `gcp` | stable | none | 83 | -- | - -AWS, Azure, and GCP cloud provider advisories were added in Sprint 007. They track platform-level security bulletins for cloud infrastructure components and are categorized under `Vendor` alongside traditional PSIRTs. - -Mirror bootstrap note: -- `oracle` default VEX bootstrap discovery uses Oracle's public security RSS feed and derived `*csaf.json` documents. -- `cisco` default VEX bootstrap uses Cisco's public CSAF provider metadata and does not require the OAuth credentials used by the Concelier openVuln connector. -- If Cisco's public paged catalog is unavailable, the bootstrap falls back to `changes.csv` and then `index.txt`, prefers newer candidates first, and checkpoints seen or permanently inaccessible legacy paths so hourly runs do not re-download or stall on the full historical corpus. - -## Linux Distributions - -| Connector | Source ID | Status | Auth | Priority | Regions | Ops Runbook | +| Category | ID | Display name | Default enabled | Requires auth | Built-in runnable | Stored config | | --- | --- | --- | --- | --- | --- | --- | -| Debian Security Tracker | `debian` | stable | none | 30 | -- | [debian.md](docs/modules/concelier/operations/connectors/debian.md) | -| Ubuntu Security Notices | `ubuntu` | stable | none | 32 | -- | [ubuntu.md](docs/modules/concelier/operations/connectors/ubuntu.md) | -| Alpine SecDB | `alpine` | stable | none | 34 | -- | [alpine.md](docs/modules/concelier/operations/connectors/alpine.md) | -| SUSE Security | `suse` | stable | none | 36 | -- | [suse.md](docs/modules/concelier/operations/connectors/suse.md) | -| RHEL Security | `rhel` | stable | none | 38 | -- | -- | -| CentOS Security | `centos` | stable | none | 40 | -- | -- | -| Fedora Security | `fedora` | stable | none | 42 | -- | -- | -| Arch Security | `arch` | stable | none | 44 | -- | -- | -| Gentoo Security | `gentoo` | stable | none | 46 | -- | -- | -| Astra Linux Security | `astra` | stable | none | 48 | RU, CIS | [astra.md](docs/modules/concelier/operations/connectors/astra.md) | +| Cert | auscert | AusCERT (Australia) | false | false | yes | none | +| Cert | cccs | CCCS (Canada) | true | false | yes | none | +| Cert | cert-at | CERT.at (Austria) | true | false | no | none | +| Cert | cert-be | CERT.be (Belgium) | true | false | no | none | +| Cert | cert-cc | CERT/CC | true | false | yes | none | +| Cert | cert-ch | NCSC-CH (Switzerland) | true | false | no | none | +| Cert | cert-de | CERT-Bund (Germany) | true | false | yes | none | +| Cert | cert-eu | CERT-EU | true | false | no | none | +| Cert | cert-fr | CERT-FR | true | false | yes | none | +| Cert | cert-in | CERT-In (India) | false | false | yes | none | +| Cert | cert-pl | CERT.PL (Poland) | false | false | no | none | +| Cert | cert-ua | CERT-UA (Ukraine) | false | false | no | none | +| Cert | fstec-bdu | FSTEC BDU (Russia) | false | false | yes | none | +| Cert | jpcert | JPCERT/CC (Japan) | true | false | yes | none | +| Cert | krcert | KrCERT/CC (South Korea) | false | false | yes | none | +| Cert | nkcki | NKCKI (Russia) | false | false | yes | none | +| Cert | us-cert | CISA (US-CERT) | true | false | yes | none | +| Container | chainguard | Chainguard Advisories | true | false | no | none | +| Container | docker-official | Docker Official CVEs | true | false | no | none | +| Csaf | csaf | CSAF Aggregator | true | false | no | none | +| Csaf | csaf-tc | CSAF TC Trusted Publishers | true | false | no | none | +| Csaf | vex | VEX Hub | true | false | no | none | +| Distribution | alpine | Alpine Security | true | false | yes | none | +| Distribution | arch | Arch Security | true | false | no | none | +| Distribution | astra | Astra Linux Security | false | false | no | none | +| Distribution | centos | CentOS Security | true | false | no | none | +| Distribution | debian | Debian Security | true | false | yes | none | +| Distribution | fedora | Fedora Security | true | false | no | none | +| Distribution | gentoo | Gentoo Security | true | false | no | none | +| Distribution | rhel | RHEL Security | true | false | no | none | +| Distribution | suse | SUSE Security | true | false | yes | none | +| Distribution | ubuntu | Ubuntu Security | true | false | yes | none | +| Ecosystem | crates | Crates.io Advisories | false | false | no | none | +| Ecosystem | go | Go Advisories | false | false | no | none | +| Ecosystem | hex | Hex.pm Advisories | false | false | no | none | +| Ecosystem | maven | Maven Advisories | false | false | no | none | +| Ecosystem | npm | npm Advisories | false | false | no | none | +| Ecosystem | nuget | NuGet Advisories | false | true | no | none | +| Ecosystem | packagist | Packagist Advisories | false | false | no | none | +| Ecosystem | pypi | PyPI Advisories | false | false | no | none | +| Ecosystem | rubygems | RubyGems Advisories | false | false | no | none | +| Exploit | exploitdb | Exploit-DB | false | false | no | none | +| Exploit | metasploit | Metasploit Modules | false | false | no | none | +| Exploit | poc-github | PoC-in-GitHub | false | true | no | none | +| Hardware | amd | AMD Security | false | false | no | none | +| Hardware | arm | ARM Security Center | false | false | no | none | +| Hardware | intel | Intel PSIRT | false | false | no | none | +| Ics | kaspersky-ics | Kaspersky ICS-CERT | false | false | yes | none | +| Ics | siemens | Siemens ProductCERT | false | false | no | none | +| Mirror | stella-mirror | StellaOps Mirror | false | false | yes | none | +| PackageManager | bundler-audit | Ruby Advisory DB | false | false | no | none | +| PackageManager | govuln | Go Vuln DB | false | false | no | none | +| PackageManager | pypa | PyPA Advisory DB | false | false | no | none | +| PackageManager | rustsec | RustSec Advisory DB | false | false | no | none | +| Primary | cve | CVE.org (MITRE) | true | false | yes | none | +| Primary | ghsa | GitHub Security Advisories | true | true | yes | UI+CLI | +| Primary | nvd | NVD (NIST) | true | false | yes | none | +| Primary | osv | OSV (Google) | true | false | yes | none | +| Threat | epss | EPSS (FIRST) | true | false | yes | none | +| Threat | kev | CISA KEV | true | false | yes | none | +| Threat | mitre-attack | MITRE ATT&CK | false | false | no | none | +| Threat | mitre-d3fend | MITRE D3FEND | false | false | no | none | +| Vendor | adobe | Adobe Security | true | false | yes | UI+CLI | +| Vendor | amazon | Amazon Linux Security | true | false | no | none | +| Vendor | apple | Apple Security | true | false | yes | none | +| Vendor | aws | AWS Security Bulletins | true | false | no | none | +| Vendor | azure | Azure Security Advisories | true | false | no | none | +| Vendor | chromium | Chromium Security | true | false | yes | UI+CLI | +| Vendor | cisco | Cisco Security | true | true | yes | UI+CLI | +| Vendor | fortinet | Fortinet PSIRT | true | false | no | none | +| Vendor | gcp | GCP Security Bulletins | true | false | no | none | +| Vendor | google | Google Security | true | false | no | none | +| Vendor | juniper | Juniper Security | true | false | no | none | +| Vendor | microsoft | Microsoft Security | true | true | yes | UI+CLI | +| Vendor | oracle | Oracle Security | true | false | yes | UI+CLI | +| Vendor | paloalto | Palo Alto Security | true | false | no | none | +| Vendor | redhat | Red Hat Security | true | false | yes | none | +| Vendor | vmware | VMware Security | true | false | yes | none | -Mirror bootstrap note: -- `ubuntu` default VEX bootstrap reads `https://ubuntu.com/security/notices.json` and synthesizes deterministic CSAF documents from the per-notice JSON payloads because Canonical's public path is notice JSON rather than native CSAF. +## Stored advisory configuration coverage -## Language Ecosystems +The current stored configuration schema covers these advisory sources: -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| npm Advisories | `npm` | stable | none | 50 | -- | -| PyPI Advisories | `pypi` | stable | none | 52 | -- | -| Go Advisories | `go` | stable | none | 54 | -- | -| RubyGems Advisories | `rubygems` | stable | none | 56 | -- | -| NuGet Advisories | `nuget` | stable | api-token | 58 | -- | -| Maven Advisories | `maven` | stable | none | 60 | -- | -| Crates.io Advisories | `crates` | stable | none | 62 | -- | -| Packagist Advisories | `packagist` | stable | none | 64 | -- | -| Hex.pm Advisories | `hex` | stable | none | 66 | -- | +- `ghsa`: GitHub API token +- `cisco`: OAuth client ID and client secret +- `microsoft`: tenant ID, client ID, and client secret +- `oracle`: calendar and advisory URI overrides +- `adobe`: bulletin index URI overrides +- `chromium`: feed URI override -Ecosystem connectors use OSV or GHSA GraphQL as the underlying data source. NuGet requires a `GITHUB_PAT` for GHSA GraphQL access. +Everything else in the advisory catalog is either: -## Package Manager Native Advisories +- public and currently fieldless on the advisory side, or +- cataloged but not wired into the built-in runnable WebService job surface yet -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| RustSec Advisory DB (cargo-audit) | `rustsec` | stable | none | 63 | -- | -| PyPA Advisory DB (pip-audit) | `pypa` | stable | none | 53 | -- | -| Go Vuln DB (govulncheck) | `govuln` | stable | none | 55 | -- | -| Ruby Advisory DB (bundler-audit) | `bundler-audit` | stable | none | 57 | -- | +## Verification state for this inventory -Package manager native advisory databases provide language-specific vulnerability data curated by the respective package manager maintainers. These complement the ecosystem feeds (OSV/GHSA) by providing authoritative tool-native data used by `cargo-audit`, `pip-audit`, `govulncheck`, and `bundler-audit`. They are categorized separately under `PackageManager` to allow targeted mirror export filtering. +Control-plane evidence reverified in Sprint `20260422_004`: -## CSAF/VEX Sources +- Advisory source catalog and status API coverage confirms built-in runnable vs catalog-only behavior for representative connectors including `nvd`, `osv`, `cccs`, `cert-cc`, `krcert`, `microsoft`, `ghsa`, `cisco`, `oracle`, `adobe`, `chromium`, and catalog-only `npm` +- Advisory stored configuration persistence is covered for `ghsa`, `adobe`, and `chromium` +- Excititor provider management endpoints are covered by targeted backend tests and linked UI/CLI work is documented in `docs/modules/excititor/operations/provider-control-plane.md` -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| CSAF Aggregator | `csaf` | stable | none | 70 | -- | -| CSAF TC Trusted Publishers | `csaf-tc` | stable | none | 72 | -- | -| VEX Hub | `vex` | stable | none | 74 | -- | - -## Exploit Databases - -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| Exploit-DB | `exploitdb` | stable | none | 110 | -- | -| PoC-in-GitHub | `poc-github` | stable | api-token | 112 | -- | -| Metasploit Modules | `metasploit` | stable | none | 114 | -- | - -Exploit databases track publicly available proof-of-concept code and exploit modules. Exploit-DB is sourced from the Offensive Security GitLab mirror. PoC-in-GitHub uses the GitHub search API to discover repositories containing vulnerability PoCs (requires `GITHUB_PAT`). Metasploit tracks Rapid7 Metasploit Framework module metadata for CVE-to-exploit correlation. - -## Container Sources - -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| Docker Official CVEs | `docker-official` | stable | none | 120 | -- | -| Chainguard Advisories | `chainguard` | stable | none | 122 | -- | - -Container-specific advisory sources track vulnerabilities in base images and hardened container distributions. Docker Official CVEs covers the Docker Hub official images program. Chainguard Advisories covers hardened distroless and Wolfi-based images. - -## Hardware/Firmware - -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| Intel PSIRT | `intel` | stable | none | 130 | -- | -| AMD Security | `amd` | stable | none | 132 | -- | -| ARM Security Center | `arm` | stable | none | 134 | -- | - -Hardware PSIRT advisories cover CPU microcode, firmware, and silicon-level vulnerabilities from the three major processor vendors. These sources are especially relevant for infrastructure operators tracking speculative execution (Spectre/Meltdown class) and firmware supply chain issues. - -## ICS/SCADA - -| Connector | Source ID | Status | Auth | Priority | Regions | Ops Runbook | -| --- | --- | --- | --- | --- | --- | --- | -| Siemens ProductCERT | `siemens` | stable | none | 136 | -- | -- | -| Kaspersky ICS-CERT | `kaspersky-ics` | stable | none | 102 | RU, CIS, GLOBAL | [kaspersky-ics.md](docs/modules/concelier/operations/connectors/kaspersky-ics.md) | - -Industrial control systems advisories cover SCADA and operational technology vulnerabilities. Siemens ProductCERT publishes CSAF-format advisories. Kaspersky ICS-CERT was promoted from beta to stable in Sprint 007 after endpoint stability verification. - -## National CERTs - -| Connector | Source ID | Status | Auth | Priority | Regions | Ops Runbook | -| --- | --- | --- | --- | --- | --- | --- | -| CERT-FR | `cert-fr` | stable | none | 80 | FR, EU | [cert-fr.md](docs/modules/concelier/operations/connectors/cert-fr.md) | -| CERT-Bund (Germany) | `cert-de` | stable | none | 82 | DE, EU | [certbund.md](docs/modules/concelier/operations/connectors/certbund.md) | -| CERT.at (Austria) | `cert-at` | stable | none | 84 | AT, EU | -- | -| CERT.be (Belgium) | `cert-be` | stable | none | 86 | BE, EU | -- | -| NCSC-CH (Switzerland) | `cert-ch` | stable | none | 88 | CH | -- | -| CERT-EU | `cert-eu` | stable | none | 90 | EU | -- | -| CCCS (Canada) | `cccs` | stable | none | 91 | CA, NA | [cccs.md](docs/modules/concelier/operations/connectors/cccs.md) | -| JPCERT/CC (Japan) | `jpcert` | stable | none | 92 | JP, APAC | [jvn.md](docs/modules/concelier/operations/connectors/jvn.md) | -| CERT/CC | `cert-cc` | stable | none | 93 | US, NA | [cert-cc.md](docs/modules/concelier/operations/connectors/cert-cc.md) | -| CISA (US-CERT) | `us-cert` | stable | none | 94 | US, NA | [ics-cisa.md](docs/modules/concelier/operations/connectors/ics-cisa.md) | -| CERT-UA (Ukraine) | `cert-ua` | stable | none | 95 | UA | -- | -| CERT.PL (Poland) | `cert-pl` | stable | none | 96 | PL, EU | -- | -| AusCERT (Australia) | `auscert` | stable | none | 97 | AU, APAC | -- | -| KrCERT/CC (South Korea) | `krcert` | stable | none | 98 | KR, APAC | [kisa.md](docs/modules/concelier/operations/connectors/kisa.md) | -| CERT-In (India) | `cert-in` | stable | none | 99 | IN, APAC | [cert-in.md](docs/modules/concelier/operations/connectors/cert-in.md) | - -Seven additional CERTs beyond the original European/Japanese set are now defined in the catalog: CCCS (Canada), CERT/CC, CERT-UA, CERT.PL, AusCERT, KrCERT/CC, and CERT-In, extending coverage to North America, Eastern Europe, Oceania, and South/East Asia. - -## Russian/CIS Sources - -| Connector | Source ID | Status | Auth | Priority | Regions | Ops Runbook | -| --- | --- | --- | --- | --- | --- | --- | -| FSTEC BDU | `fstec-bdu` | stable | none | 100 | RU, CIS | [fstec-bdu.md](docs/modules/concelier/operations/connectors/fstec-bdu.md) | -| NKCKI | `nkcki` | stable | none | 101 | RU, CIS | [nkcki.md](docs/modules/concelier/operations/connectors/nkcki.md) | - -FSTEC BDU and NKCKI were promoted from beta to stable in Sprint 007. FSTEC BDU (Bank of Security Threats) provides vulnerability data maintained by Russia's Federal Service for Technical and Export Control. NKCKI is the National Coordination Center for Computer Incidents. Kaspersky ICS-CERT and Astra Linux are listed in their respective category sections above. - -## StellaOps Mirror - -| Connector | Source ID | Status | Auth | Priority | Ops Runbook | -| --- | --- | --- | --- | --- | --- | -| StellaOps Mirror | `stella-mirror` | stable | none (configurable) | 1 | -- | - -The StellaOps Mirror connector consumes pre-aggregated advisory data from a StellaOps mirror instance. When using mirror mode, this source takes highest priority (1) and replaces direct upstream connections. See `docs/modules/excititor/mirrors.md` for mirror configuration details. - ---- - -**Reason Codes Reference:** [docs/modules/concelier/operations/connectors/reason-codes.md](docs/modules/concelier/operations/connectors/reason-codes.md) +This page does not claim that all 78 advisory connectors were end-to-end re-ingested in this sprint. It records catalog truth, built-in host wiring, stored configuration coverage, and the specific control-plane verification completed during this implementation slice. diff --git a/docs/modules/excititor/README.md b/docs/modules/excititor/README.md index cc7843b2f..2a8ba53f5 100644 --- a/docs/modules/excititor/README.md +++ b/docs/modules/excititor/README.md @@ -40,6 +40,7 @@ Excititor converts heterogeneous VEX feeds into raw observations and linksets th - PostgreSQL (schema `vex`) for observation storage and job metadata. - Offline kit packaging aligned with Concelier merges. - Connector-specific runbooks (see `docs/modules/concelier/operations/connectors`). +- Provider control plane inventory and readiness notes: [`operations/provider-control-plane.md`](./operations/provider-control-plane.md) - Ubuntu CSAF provenance knobs: [`operations/ubuntu-csaf.md`](operations/ubuntu-csaf.md) captures TrustWeight/Tier, cosign, and fingerprint configuration for the sprint 120 enrichment. ## Backlog references diff --git a/docs/modules/excititor/operations/provider-control-plane.md b/docs/modules/excititor/operations/provider-control-plane.md new file mode 100644 index 000000000..72261cebe --- /dev/null +++ b/docs/modules/excititor/operations/provider-control-plane.md @@ -0,0 +1,97 @@ +# Excititor Provider Control Plane + +This document describes the operator-facing control plane for Excititor VEX providers. + +## Operator entry points + +- Web UI: `Ops -> Integrations -> Advisory & VEX Sources -> VEX Providers` +- CLI: + - `stella excititor list-providers` + - `stella excititor show-provider --provider ` + - `stella excititor enable-provider --provider ` + - `stella excititor disable-provider --provider ` + - `stella excititor run-provider --provider [--since ... --window ... --force]` + - `stella excititor update-provider --provider ...` + +Backend API: + +- `GET /excititor/providers` +- `GET /excititor/providers/{providerId}` +- `PUT /excititor/providers/{providerId}` +- `POST /excititor/providers/{providerId}/enable` +- `POST /excititor/providers/{providerId}/disable` +- `POST /excititor/providers/{providerId}/run` + +## Readiness states + +Excititor providers use four runtime readiness states: + +- `ready`: persisted-enabled and the current host has a runnable connector +- `blocked`: persisted-enabled but currently cooling down or otherwise not runnable +- `disabled`: persisted-disabled +- `planned`: cataloged provider without a runnable connector registered on the current host + +`enabled` remains the operator intent flag. A provider can be `enabled=true` and still show `planned` or `blocked`. + +## Provider inventory + +| Provider ID | Kind | Default enabled | Registered in WebService | UI control plane | CLI control plane | Credential / config status | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `excititor:redhat` | distro | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:ubuntu` | distro | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:oracle` | vendor | true | yes | yes | yes | Public defaults; metadata and trust overrides can be persisted through the provider control plane. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:cisco` | vendor | true | yes | yes | yes | Public CSAF metadata works without a persisted secret path. Optional API token support exists in connector host options, not in the new persisted UI or CLI surface. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:suse-rancher` | hub | false | yes | yes | yes | Discovery and trust metadata can be persisted. Authenticated discovery credentials remain host-config only today. Anonymous discovery can still be allowed by connector options. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:oci-openvex` | attestation | false | yes | yes | yes | Provider metadata and trust overrides can be persisted. Image subscriptions, registry credentials, and cosign credential material remain host-config only today. | Registered by default in `StellaOps.Excititor.WebService`. | +| `excititor:msrc` | vendor | false | conditional | yes | yes | Persisted provider metadata exists, but MSRC connector credentials and offline token settings remain host-config only today. | Registered only when `Excititor:Connectors:Msrc` exists in host configuration. Otherwise the provider remains `planned`. | + +## What the current provider control plane persists + +The current `update-provider` surface persists: + +- display name +- provider kind +- base URIs +- well-known metadata URI +- ROLIE service URI +- trust weight +- PGP fingerprints +- cosign issuer +- cosign identity pattern +- enabled / disabled intent + +The current provider control plane does not yet persist connector-secret fields such as: + +- MSRC tenant, client ID, client secret, static access token, or offline token path +- Rancher Hub token endpoint, client ID, or client secret +- OCI registry username, password, identity token, refresh token, cosign key pair, or image subscription list +- Cisco optional VEX API token + +Those settings still come from host configuration today. The control plane is truthful about the gap by surfacing `planned` or `blocked` readiness instead of pretending the connector is runnable. + +## Host wiring notes + +- `StellaOps.Excititor.WebService` always registers: + - Red Hat CSAF + - Ubuntu CSAF + - Oracle CSAF + - Cisco CSAF + - SUSE Rancher VEX Hub + - OCI OpenVEX attestations +- `StellaOps.Excititor.WebService` registers Microsoft MSRC CSAF only when the `Excititor:Connectors:Msrc` configuration section exists. +- `StellaOps.Excititor.Worker` seeds these public defaults when no explicit provider schedule list is supplied: + - `excititor:redhat` + - `excititor:ubuntu` + - `excititor:oracle` + - `excititor:cisco` + +## Verification state + +Reverified in Sprint `20260422_004`: + +- targeted backend tests for provider management endpoints passed against `StellaOps.Excititor.WebService.Tests.ProviderManagementEndpointsTests` +- Angular route helper spec passed for the new VEX provider route +- Angular development build passed with the new provider catalog page +- CLI provider inspection and control verbs were added for the backend surface + +The verification above covers the provider control plane and readiness behavior. It does not claim a fresh live ingest verification run for every upstream VEX provider in this sprint. diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index d686fd7d8..88bc55ab4 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -1855,14 +1855,15 @@ internal static partial class CommandHandlers { if (AnsiConsole.Profile.Capabilities.Interactive) { - var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Trust", "Enabled", "Last Ingested"); + var table = new Table().Border(TableBorder.Rounded).AddColumns("Provider", "Kind", "Readiness", "Enabled", "Trust", "Last Ingested"); foreach (var provider in providers) { table.AddRow( provider.Id, provider.Kind, - string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier, + provider.Readiness, provider.Enabled ? "yes" : "no", + string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier, provider.LastIngestedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown"); } @@ -1872,9 +1873,10 @@ internal static partial class CommandHandlers { foreach (var provider in providers) { - logger.LogInformation("{ProviderId} [{Kind}] Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}", + logger.LogInformation("{ProviderId} [{Kind}] Readiness={Readiness} Enabled={Enabled} Trust={Trust} LastIngested={LastIngested}", provider.Id, provider.Kind, + provider.Readiness, provider.Enabled ? "yes" : "no", string.IsNullOrWhiteSpace(provider.TrustTier) ? "-" : provider.TrustTier, provider.LastIngestedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"); @@ -1893,6 +1895,219 @@ internal static partial class CommandHandlers } } + public static async Task HandleExcititorShowProviderAsync( + IServiceProvider services, + string? providerId, + bool verbose, + CancellationToken cancellationToken) + { + var normalizedProviderId = NormalizeExcititorProviderId(providerId); + if (normalizedProviderId is null) + { + await WriteValidationErrorAsync("Provider identifier is required. Use --provider .").ConfigureAwait(false); + return; + } + + await using var scope = services.CreateAsyncScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("excititor-show-provider"); + var verbosity = scope.ServiceProvider.GetRequiredService(); + var previousLevel = verbosity.MinimumLevel; + verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; + using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.show-provider", ActivityKind.Client); + activity?.SetTag("stellaops.cli.command", "excititor show-provider"); + activity?.SetTag("stellaops.cli.provider", normalizedProviderId); + using var duration = CliMetrics.MeasureCommandDuration("excititor show-provider"); + + try + { + var providers = await client.GetExcititorProvidersAsync(includeDisabled: true, cancellationToken).ConfigureAwait(false); + var provider = FindExcititorProvider(providers, normalizedProviderId); + if (provider is null) + { + logger.LogError("Excititor provider {ProviderId} was not found.", normalizedProviderId); + Environment.ExitCode = 1; + return; + } + + WriteExcititorProviderDetails(provider, logger); + Environment.ExitCode = 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to show Excititor provider {ProviderId}.", normalizedProviderId); + Environment.ExitCode = 1; + } + finally + { + verbosity.MinimumLevel = previousLevel; + } + } + + public static Task HandleExcititorSetProviderEnabledAsync( + IServiceProvider services, + string? providerId, + bool enabled, + bool verbose, + CancellationToken cancellationToken) + => HandleExcititorProviderMutationAsync( + services, + commandName: enabled ? "excititor enable-provider" : "excititor disable-provider", + providerId, + verbose, + new Dictionary + { + ["enabled"] = enabled + }, + (client, normalizedProviderId, ct) => client.ExecuteExcititorOperationAsync( + $"providers/{Uri.EscapeDataString(normalizedProviderId)}/{(enabled ? "enable" : "disable")}", + HttpMethod.Post, + null, + ct), + cancellationToken); + + public static Task HandleExcititorRunProviderAsync( + IServiceProvider services, + string? providerId, + DateTimeOffset? since, + TimeSpan? window, + bool force, + bool verbose, + CancellationToken cancellationToken) + { + var payload = new Dictionary(StringComparer.Ordinal); + if (since.HasValue) + { + payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + } + if (window.HasValue) + { + payload["window"] = window.Value.ToString("c", CultureInfo.InvariantCulture); + } + if (force) + { + payload["force"] = true; + } + + return HandleExcititorProviderMutationAsync( + services, + commandName: "excititor run-provider", + providerId, + verbose, + new Dictionary + { + ["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + ["window"] = window?.ToString("c", CultureInfo.InvariantCulture), + ["force"] = force + }, + (client, normalizedProviderId, ct) => client.ExecuteExcititorOperationAsync( + $"providers/{Uri.EscapeDataString(normalizedProviderId)}/run", + HttpMethod.Post, + RemoveNullValues(payload), + ct), + cancellationToken); + } + + public static Task HandleExcititorUpdateProviderAsync( + IServiceProvider services, + string? providerId, + string? displayName, + string? kind, + IReadOnlyList baseUris, + string? wellKnownMetadataUri, + string? rolieServiceUri, + double? trustWeight, + IReadOnlyList pgpFingerprints, + string? cosignIssuer, + string? cosignIdentityPattern, + bool verbose, + CancellationToken cancellationToken) + { + var normalizedBaseUris = NormalizeExcititorStringList(baseUris); + var normalizedPgpFingerprints = NormalizeExcititorStringList(pgpFingerprints); + var payload = new Dictionary(StringComparer.Ordinal); + + if (!string.IsNullOrWhiteSpace(displayName)) + { + payload["displayName"] = displayName.Trim(); + } + if (!string.IsNullOrWhiteSpace(kind)) + { + payload["kind"] = kind.Trim(); + } + if (normalizedBaseUris.Count > 0) + { + payload["baseUris"] = normalizedBaseUris; + } + + var discovery = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(wellKnownMetadataUri)) + { + discovery["wellKnownMetadataUri"] = wellKnownMetadataUri.Trim(); + } + if (!string.IsNullOrWhiteSpace(rolieServiceUri)) + { + discovery["rolieServiceUri"] = rolieServiceUri.Trim(); + } + if (discovery.Count > 0) + { + payload["discovery"] = RemoveNullValues(discovery); + } + + var trust = new Dictionary(StringComparer.Ordinal); + if (trustWeight.HasValue) + { + trust["weight"] = trustWeight.Value; + } + if (normalizedPgpFingerprints.Count > 0) + { + trust["pgpFingerprints"] = normalizedPgpFingerprints; + } + + var cosign = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(cosignIssuer)) + { + cosign["issuer"] = cosignIssuer.Trim(); + } + if (!string.IsNullOrWhiteSpace(cosignIdentityPattern)) + { + cosign["identityPattern"] = cosignIdentityPattern.Trim(); + } + if (cosign.Count > 0) + { + trust["cosign"] = RemoveNullValues(cosign); + } + if (trust.Count > 0) + { + payload["trust"] = RemoveNullValues(trust); + } + + if (payload.Count == 0) + { + return WriteValidationErrorAsync("At least one provider field must be supplied to update-provider."); + } + + return HandleExcititorProviderMutationAsync( + services, + commandName: "excititor update-provider", + providerId, + verbose, + new Dictionary + { + ["display_name"] = displayName, + ["kind"] = kind, + ["base_uri_count"] = normalizedBaseUris.Count, + ["pgp_fingerprint_count"] = normalizedPgpFingerprints.Count, + ["trust_weight"] = trustWeight + }, + (client, normalizedProviderId, ct) => client.ExecuteExcititorOperationAsync( + $"providers/{Uri.EscapeDataString(normalizedProviderId)}", + HttpMethod.Put, + RemoveNullValues(payload), + ct), + cancellationToken); + } + public static async Task HandleExcititorExportAsync( IServiceProvider services, string format, @@ -5122,6 +5337,221 @@ internal static partial class CommandHandlers } } + private static async Task HandleExcititorProviderMutationAsync( + IServiceProvider services, + string commandName, + string? providerId, + bool verbose, + IDictionary? activityTags, + Func> operation, + CancellationToken cancellationToken) + { + var normalizedProviderId = NormalizeExcititorProviderId(providerId); + if (normalizedProviderId is null) + { + await WriteValidationErrorAsync("Provider identifier is required. Use --provider .").ConfigureAwait(false); + return; + } + + await using var scope = services.CreateAsyncScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService().CreateLogger(commandName.Replace(' ', '-')); + var verbosity = scope.ServiceProvider.GetRequiredService(); + var previousLevel = verbosity.MinimumLevel; + verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information; + using var activity = CliActivitySource.Instance.StartActivity($"cli.{commandName.Replace(' ', '.')}", ActivityKind.Client); + activity?.SetTag("stellaops.cli.command", commandName); + activity?.SetTag("stellaops.cli.provider", normalizedProviderId); + if (activityTags is not null) + { + foreach (var tag in activityTags) + { + activity?.SetTag(tag.Key, tag.Value); + } + } + using var duration = CliMetrics.MeasureCommandDuration(commandName); + + try + { + var result = await operation(client, normalizedProviderId, cancellationToken).ConfigureAwait(false); + if (!result.Success) + { + logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Operation failed." : result.Message); + Environment.ExitCode = 1; + return; + } + + if (!string.IsNullOrWhiteSpace(result.Message)) + { + logger.LogInformation(result.Message); + } + + var providers = await client.GetExcititorProvidersAsync(includeDisabled: true, cancellationToken).ConfigureAwait(false); + var provider = FindExcititorProvider(providers, normalizedProviderId); + if (provider is not null) + { + WriteExcititorProviderDetails(provider, logger); + } + else if (!string.IsNullOrWhiteSpace(result.Location)) + { + logger.LogInformation("Location: {Location}", result.Location); + } + + Environment.ExitCode = 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Excititor provider operation failed."); + Environment.ExitCode = 1; + } + finally + { + verbosity.MinimumLevel = previousLevel; + } + } + + private static ExcititorProviderSummary? FindExcititorProvider( + IReadOnlyList providers, + string providerId) + { + return providers.FirstOrDefault(provider => ProviderIdMatches(provider.Id, providerId)); + } + + private static bool ProviderIdMatches(string candidate, string expected) + { + if (string.Equals(candidate, expected, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + const string Prefix = "excititor:"; + if (candidate.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) + && string.Equals(candidate[Prefix.Length..], expected, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return expected.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) + && string.Equals(expected[Prefix.Length..], candidate, StringComparison.OrdinalIgnoreCase); + } + + private static string? NormalizeExcititorProviderId(string? providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return null; + } + + return providerId.Trim(); + } + + private static IReadOnlyList NormalizeExcititorStringList(IReadOnlyList? values) + { + if (values is null || values.Count == 0) + { + return Array.Empty(); + } + + return values + .Select(static value => value?.Trim()) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static void WriteExcititorProviderDetails(ExcititorProviderSummary provider, ILogger logger) + { + if (AnsiConsole.Profile.Capabilities.Interactive) + { + var table = new Table().Border(TableBorder.Rounded).AddColumns("Field", "Value"); + AddExcititorProviderRow(table, "Provider", provider.Id); + AddExcititorProviderRow(table, "Display Name", provider.DisplayName); + AddExcititorProviderRow(table, "Kind", provider.Kind); + AddExcititorProviderRow(table, "Readiness", provider.Readiness); + AddExcititorProviderRow(table, "Sync State", provider.SyncState); + AddExcititorProviderRow(table, "Enabled", provider.Enabled ? "yes" : "no"); + AddExcititorProviderRow(table, "Default Enabled", provider.DefaultEnabled ? "yes" : "no"); + AddExcititorProviderRow(table, "Sync Supported", provider.SyncSupported ? "yes" : "no"); + AddExcititorProviderRow(table, "Ready For Sync", provider.ReadyForSync ? "yes" : "no"); + AddExcititorProviderRow(table, "Configuration Supported", provider.ConfigurationSupported ? "yes" : "no"); + AddExcititorProviderRow(table, "Source", provider.Source); + AddExcititorProviderRow(table, "Trust Tier", provider.TrustTier); + AddExcititorProviderRow(table, "Trust Weight", provider.TrustWeight?.ToString("0.###", CultureInfo.InvariantCulture) ?? "-"); + AddExcititorProviderRow(table, "Last Ingested", FormatExcititorTimestamp(provider.LastIngestedAt)); + AddExcititorProviderRow(table, "Last Updated", FormatExcititorTimestamp(provider.LastUpdatedAt)); + AddExcititorProviderRow(table, "Next Eligible Run", FormatExcititorTimestamp(provider.NextEligibleRunAt)); + AddExcititorProviderRow(table, "Blocking Reason", provider.BlockingReason); + AddExcititorProviderRow(table, "Last Failure", provider.LastFailureReason); + AddExcititorProviderRow(table, "Description", provider.Description); + AddExcititorProviderRow(table, "Base URIs", JoinExcititorValues(provider.BaseUris)); + AddExcititorProviderRow(table, "Well-Known Metadata", provider.WellKnownMetadataUri); + AddExcititorProviderRow(table, "ROLIE Service", provider.RolieServiceUri); + AddExcititorProviderRow(table, "PGP Fingerprints", JoinExcititorValues(provider.PgpFingerprints)); + AddExcititorProviderRow(table, "Cosign Issuer", provider.CosignIssuer); + AddExcititorProviderRow(table, "Cosign Identity Pattern", provider.CosignIdentityPattern); + AddExcititorProviderRow(table, "Failure Count", provider.FailureCount.ToString(CultureInfo.InvariantCulture)); + AnsiConsole.Write(table); + return; + } + + logger.LogInformation("Provider: {ProviderId}", provider.Id); + logger.LogInformation(" DisplayName: {DisplayName}", provider.DisplayName); + logger.LogInformation(" Kind: {Kind}", provider.Kind); + logger.LogInformation(" Readiness: {Readiness}", provider.Readiness); + logger.LogInformation(" SyncState: {SyncState}", provider.SyncState); + logger.LogInformation(" Enabled: {Enabled}", provider.Enabled ? "yes" : "no"); + logger.LogInformation(" TrustTier: {TrustTier}", DisplayExcititorValue(provider.TrustTier)); + logger.LogInformation(" Source: {Source}", DisplayExcititorValue(provider.Source)); + logger.LogInformation(" LastIngested: {LastIngested}", FormatExcititorTimestamp(provider.LastIngestedAt)); + logger.LogInformation(" NextEligibleRun: {NextEligibleRun}", FormatExcititorTimestamp(provider.NextEligibleRunAt)); + if (!string.IsNullOrWhiteSpace(provider.BlockingReason)) + { + logger.LogInformation(" BlockingReason: {BlockingReason}", provider.BlockingReason); + } + if (!string.IsNullOrWhiteSpace(provider.Description)) + { + logger.LogInformation(" Description: {Description}", provider.Description); + } + } + + private static void AddExcititorProviderRow(Table table, string label, string? value) + { + table.AddRow(Markup.Escape(label), Markup.Escape(DisplayExcititorValue(value))); + } + + private static string DisplayExcititorValue(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + + private static string JoinExcititorValues(IReadOnlyList? values) + { + if (values is null || values.Count == 0) + { + return "-"; + } + + var filtered = values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .ToArray(); + + return filtered.Length == 0 ? "-" : string.Join(", ", filtered); + } + + private static string FormatExcititorTimestamp(DateTimeOffset? value) + => value?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture) ?? "unknown"; + + private static Task WriteValidationErrorAsync(string message) + { + if (!string.IsNullOrWhiteSpace(message)) + { + Console.Error.WriteLine(message.Trim()); + } + + Environment.ExitCode = 1; + return Task.CompletedTask; + } + private static async Task> GatherImageDigestsAsync( IReadOnlyList inline, string? filePath, diff --git a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs index e8264448c..e48dc57e0 100644 --- a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs +++ b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs @@ -1594,8 +1594,56 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient var trustTier = GetStringProperty(item, "trustTier") ?? string.Empty; var enabled = GetBooleanProperty(item, "enabled", defaultValue: true); var lastIngested = GetDateTimeOffsetProperty(item, "lastIngestedAt"); + var readiness = GetStringProperty(item, "readiness") ?? "unknown"; + var syncState = GetStringProperty(item, "syncState") ?? readiness; + var defaultEnabled = GetBooleanProperty(item, "defaultEnabled", defaultValue: false); + var syncSupported = GetBooleanProperty(item, "syncSupported", defaultValue: false); + var readyForSync = GetBooleanProperty(item, "readyForSync", defaultValue: false); + var configurationSupported = GetBooleanProperty(item, "configurationSupported", defaultValue: false); + var source = GetStringProperty(item, "source") ?? "unknown"; + var description = GetStringProperty(item, "description"); + var blockingReasonCode = GetStringProperty(item, "blockingReasonCode"); + var blockingReason = GetStringProperty(item, "blockingReason"); + var lastFailureReason = GetStringProperty(item, "lastFailureReason"); + var wellKnownMetadataUri = GetStringProperty(item, "wellKnownMetadataUri"); + var rolieServiceUri = GetStringProperty(item, "rolieServiceUri"); + var trustWeight = GetDoubleProperty(item, "trustWeight"); + var baseUris = GetStringArrayProperty(item, "baseUris"); + var pgpFingerprints = GetStringArrayProperty(item, "pgpFingerprints"); + var cosignIssuer = GetStringProperty(item, "cosignIssuer"); + var cosignIdentityPattern = GetStringProperty(item, "cosignIdentityPattern"); + var failureCount = GetInt32Property(item, "failureCount", defaultValue: 0); + var lastUpdatedAt = GetDateTimeOffsetProperty(item, "lastUpdatedAt"); + var nextEligibleRunAt = GetDateTimeOffsetProperty(item, "nextEligibleRunAt"); - list.Add(new ExcititorProviderSummary(id, kind, displayName, trustTier, enabled, lastIngested)); + list.Add(new ExcititorProviderSummary( + id, + kind, + displayName, + trustTier, + enabled, + lastIngested, + readiness, + syncState, + defaultEnabled, + syncSupported, + readyForSync, + configurationSupported, + source, + description, + blockingReasonCode, + blockingReason, + lastFailureReason, + wellKnownMetadataUri, + rolieServiceUri, + trustWeight, + baseUris, + pgpFingerprints, + cosignIssuer, + cosignIdentityPattern, + failureCount, + lastUpdatedAt, + nextEligibleRunAt)); } return list; @@ -2731,6 +2779,61 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient return defaultValue; } + private static int GetInt32Property(JsonElement element, string propertyName, int defaultValue) + { + if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) + { + return property.ValueKind switch + { + JsonValueKind.Number when property.TryGetInt32(out var parsed) => parsed, + JsonValueKind.String when int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => defaultValue + }; + } + + return defaultValue; + } + + private static double? GetDoubleProperty(JsonElement element, string propertyName) + { + if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) + { + return property.ValueKind switch + { + JsonValueKind.Number when property.TryGetDouble(out var parsed) => parsed, + JsonValueKind.String when double.TryParse(property.GetString(), NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => null + }; + } + + return null; + } + + private static IReadOnlyList GetStringArrayProperty(JsonElement element, string propertyName) + { + if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.Array) + { + var values = new List(); + foreach (var item in property.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + { + continue; + } + + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + values.Add(value); + } + } + + return values; + } + + return Array.Empty(); + } + private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String) diff --git a/src/Cli/StellaOps.Cli/Services/Models/ExcititorProviderSummary.cs b/src/Cli/StellaOps.Cli/Services/Models/ExcititorProviderSummary.cs index 496e15b19..46821e4b4 100644 --- a/src/Cli/StellaOps.Cli/Services/Models/ExcititorProviderSummary.cs +++ b/src/Cli/StellaOps.Cli/Services/Models/ExcititorProviderSummary.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace StellaOps.Cli.Services.Models; @@ -8,4 +9,25 @@ internal sealed record ExcititorProviderSummary( string DisplayName, string TrustTier, bool Enabled, - DateTimeOffset? LastIngestedAt); + DateTimeOffset? LastIngestedAt, + string Readiness = "unknown", + string SyncState = "unknown", + bool DefaultEnabled = false, + bool SyncSupported = false, + bool ReadyForSync = false, + bool ConfigurationSupported = false, + string Source = "unknown", + string? Description = null, + string? BlockingReasonCode = null, + string? BlockingReason = null, + string? LastFailureReason = null, + string? WellKnownMetadataUri = null, + string? RolieServiceUri = null, + double? TrustWeight = null, + IReadOnlyList? BaseUris = null, + IReadOnlyList? PgpFingerprints = null, + string? CosignIssuer = null, + string? CosignIdentityPattern = null, + int FailureCount = 0, + DateTimeOffset? LastUpdatedAt = null, + DateTimeOffset? NextEligibleRunAt = null); diff --git a/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs b/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs index e972437a6..7414b071f 100644 --- a/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs +++ b/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/NonCoreCliCommandModule.cs @@ -135,6 +135,185 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule return CommandHandlers.HandleExcititorListProvidersAsync(services, includeDisabled, verbose, cancellationToken); }); + var show = new Command("show-provider", "Show detailed Excititor provider status and trust settings."); + var showProviderOption = new Option("--provider", new[] { "-p" }) + { + Description = "Provider identifier to inspect." + }; + show.Add(showProviderOption); + show.SetAction((parseResult, _) => + { + var providerId = parseResult.GetValue(showProviderOption); + var verbose = parseResult.GetValue(verboseOption); + return CommandHandlers.HandleExcititorShowProviderAsync(services, providerId, verbose, cancellationToken); + }); + + var enableProvider = new Command("enable-provider", "Enable a specific Excititor provider."); + var enableProviderOption = new Option("--provider", new[] { "-p" }) + { + Description = "Provider identifier to enable." + }; + enableProvider.Add(enableProviderOption); + enableProvider.SetAction((parseResult, _) => + { + var providerId = parseResult.GetValue(enableProviderOption); + var verbose = parseResult.GetValue(verboseOption); + return CommandHandlers.HandleExcititorSetProviderEnabledAsync( + services, + providerId, + enabled: true, + verbose, + cancellationToken); + }); + + var disableProvider = new Command("disable-provider", "Disable a specific Excititor provider."); + var disableProviderOption = new Option("--provider", new[] { "-p" }) + { + Description = "Provider identifier to disable." + }; + disableProvider.Add(disableProviderOption); + disableProvider.SetAction((parseResult, _) => + { + var providerId = parseResult.GetValue(disableProviderOption); + var verbose = parseResult.GetValue(verboseOption); + return CommandHandlers.HandleExcititorSetProviderEnabledAsync( + services, + providerId, + enabled: false, + verbose, + cancellationToken); + }); + + var runProvider = new Command("run-provider", "Run a specific Excititor provider immediately when it is ready."); + var runProviderOption = new Option("--provider", new[] { "-p" }) + { + Description = "Provider identifier to run." + }; + var runProviderSinceOption = new Option("--since") + { + Description = "Optional ISO-8601 timestamp to begin the ingest window." + }; + var runProviderWindowOption = new Option("--window") + { + Description = "Optional window duration (e.g. 24:00:00)." + }; + var runProviderForceOption = new Option("--force") + { + Description = "Force the provider run even when no new work is reported." + }; + runProvider.Add(runProviderOption); + runProvider.Add(runProviderSinceOption); + runProvider.Add(runProviderWindowOption); + runProvider.Add(runProviderForceOption); + runProvider.SetAction((parseResult, _) => + { + var providerId = parseResult.GetValue(runProviderOption); + var sinceValue = parseResult.GetValue(runProviderSinceOption); + var windowValue = parseResult.GetValue(runProviderWindowOption); + if (!NonCoreCliOptionParser.TryParseIsoTimestamp(sinceValue, out var since, out var errorMessage)) + { + return ValidationFailedAsync(errorMessage); + } + + if (!NonCoreCliOptionParser.TryParseDuration(windowValue, out var window, out errorMessage)) + { + return ValidationFailedAsync(errorMessage); + } + + var force = parseResult.GetValue(runProviderForceOption); + var verbose = parseResult.GetValue(verboseOption); + return CommandHandlers.HandleExcititorRunProviderAsync( + services, + providerId, + since, + window, + force, + verbose, + cancellationToken); + }); + + var updateProvider = new Command("update-provider", "Update persisted Excititor provider metadata and trust settings."); + var updateProviderOption = new Option("--provider", new[] { "-p" }) + { + Description = "Provider identifier to update." + }; + var displayNameOption = new Option("--display-name") + { + Description = "Optional provider display name override." + }; + var kindOption = new Option("--kind") + { + Description = "Optional provider kind override." + }; + var baseUriOption = new Option("--base-uri") + { + Description = "Optional base URI values for the provider (repeatable).", + Arity = ArgumentArity.ZeroOrMore + }; + var wellKnownMetadataUriOption = new Option("--well-known-metadata-uri") + { + Description = "Optional well-known metadata URI override." + }; + var rolieServiceUriOption = new Option("--rolie-service-uri") + { + Description = "Optional ROLIE service URI override." + }; + var trustWeightOption = new Option("--trust-weight") + { + Description = "Optional trust weight override." + }; + var pgpFingerprintOption = new Option("--pgp-fingerprint") + { + Description = "Optional trusted PGP fingerprint values (repeatable).", + Arity = ArgumentArity.ZeroOrMore + }; + var cosignIssuerOption = new Option("--cosign-issuer") + { + Description = "Optional trusted cosign issuer override." + }; + var cosignIdentityPatternOption = new Option("--cosign-identity-pattern") + { + Description = "Optional trusted cosign identity pattern override." + }; + updateProvider.Add(updateProviderOption); + updateProvider.Add(displayNameOption); + updateProvider.Add(kindOption); + updateProvider.Add(baseUriOption); + updateProvider.Add(wellKnownMetadataUriOption); + updateProvider.Add(rolieServiceUriOption); + updateProvider.Add(trustWeightOption); + updateProvider.Add(pgpFingerprintOption); + updateProvider.Add(cosignIssuerOption); + updateProvider.Add(cosignIdentityPatternOption); + updateProvider.SetAction((parseResult, _) => + { + var providerId = parseResult.GetValue(updateProviderOption); + var displayName = parseResult.GetValue(displayNameOption); + var kind = parseResult.GetValue(kindOption); + var baseUris = parseResult.GetValue(baseUriOption) ?? Array.Empty(); + var wellKnownMetadataUri = parseResult.GetValue(wellKnownMetadataUriOption); + var rolieServiceUri = parseResult.GetValue(rolieServiceUriOption); + var trustWeight = parseResult.GetValue(trustWeightOption); + var pgpFingerprints = parseResult.GetValue(pgpFingerprintOption) ?? Array.Empty(); + var cosignIssuer = parseResult.GetValue(cosignIssuerOption); + var cosignIdentityPattern = parseResult.GetValue(cosignIdentityPatternOption); + var verbose = parseResult.GetValue(verboseOption); + return CommandHandlers.HandleExcititorUpdateProviderAsync( + services, + providerId, + displayName, + kind, + baseUris, + wellKnownMetadataUri, + rolieServiceUri, + trustWeight, + pgpFingerprints, + cosignIssuer, + cosignIdentityPattern, + verbose, + cancellationToken); + }); + var export = new Command("export", "Trigger Excititor export generation."); var formatOption = new Option("--format") { @@ -288,6 +467,11 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule excititor.Add(pull); excititor.Add(resume); excititor.Add(list); + excititor.Add(show); + excititor.Add(enableProvider); + excititor.Add(disableProvider); + excititor.Add(runProvider); + excititor.Add(updateProvider); excititor.Add(export); excititor.Add(backfill); excititor.Add(verify); diff --git a/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/TASKS.md b/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/TASKS.md index 34438098e..ab8364b4b 100644 --- a/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/TASKS.md +++ b/src/Cli/__Libraries/StellaOps.Cli.Plugins.NonCore/TASKS.md @@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | AUDIT-0139-T | DONE | Revalidated 2026-01-06. | | AUDIT-0139-A | TODO | Revalidated 2026-01-06 (open findings: missing command parsing tests). | | REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. | +| CONN-CTRL-02 | DONE | 2026-04-22: Added Excititor provider inspection and control verbs (`list-providers`, `show-provider`, `enable-provider`, `disable-provider`, `run-provider`, `update-provider`) against the new provider control-plane API. | diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs index a729e3a0e..c3ae4cff5 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs @@ -1903,6 +1903,166 @@ public sealed class CommandHandlersTests } } + [Fact] + public async Task HandleExcititorShowProviderAsync_FindsCanonicalProviderFromAlias() + { + var original = Environment.ExitCode; + try + { + var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) + { + ProviderSummaries = new[] + { + new ExcititorProviderSummary( + "excititor:redhat", + "csaf", + "Red Hat CSAF", + "high", + true, + DateTimeOffset.UtcNow, + Readiness: "ready") + } + }; + + var provider = BuildServiceProvider(backend); + await CommandHandlers.HandleExcititorShowProviderAsync(provider, "redhat", verbose: false, cancellationToken: CancellationToken.None); + + Assert.Equal(0, Environment.ExitCode); + } + finally + { + Environment.ExitCode = original; + } + } + + [Fact] + public async Task HandleExcititorSetProviderEnabledAsync_UsesProviderControlPlaneRoute() + { + var original = Environment.ExitCode; + try + { + var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) + { + ProviderSummaries = new[] + { + new ExcititorProviderSummary("excititor:redhat", "csaf", "Red Hat CSAF", "high", true, DateTimeOffset.UtcNow) + } + }; + + var provider = BuildServiceProvider(backend); + await CommandHandlers.HandleExcititorSetProviderEnabledAsync( + provider, + "excititor:redhat", + enabled: true, + verbose: false, + cancellationToken: CancellationToken.None); + + Assert.Equal(0, Environment.ExitCode); + Assert.Equal("providers/excititor%3Aredhat/enable", backend.LastExcititorRoute); + Assert.Equal(HttpMethod.Post, backend.LastExcititorMethod); + } + finally + { + Environment.ExitCode = original; + } + } + + [Fact] + public async Task HandleExcititorRunProviderAsync_SendsSinceWindowAndForcePayload() + { + var original = Environment.ExitCode; + var since = new DateTimeOffset(2026, 4, 22, 10, 30, 0, TimeSpan.Zero); + try + { + var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) + { + ProviderSummaries = new[] + { + new ExcititorProviderSummary("excititor:redhat", "csaf", "Red Hat CSAF", "high", true, DateTimeOffset.UtcNow) + } + }; + + var provider = BuildServiceProvider(backend); + await CommandHandlers.HandleExcititorRunProviderAsync( + provider, + "excititor:redhat", + since, + TimeSpan.FromHours(12), + force: true, + verbose: false, + cancellationToken: CancellationToken.None); + + Assert.Equal(0, Environment.ExitCode); + Assert.Equal("providers/excititor%3Aredhat/run", backend.LastExcititorRoute); + Assert.Equal(HttpMethod.Post, backend.LastExcititorMethod); + var payload = Assert.IsAssignableFrom>(backend.LastExcititorPayload); + Assert.Equal("2026-04-22T10:30:00.0000000+00:00", payload["since"]); + Assert.Equal("12:00:00", payload["window"]); + Assert.Equal(true, payload["force"]); + } + finally + { + Environment.ExitCode = original; + } + } + + [Fact] + public async Task HandleExcititorUpdateProviderAsync_SendsNestedMutationPayload() + { + var original = Environment.ExitCode; + try + { + var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null)) + { + ProviderSummaries = new[] + { + new ExcititorProviderSummary("excititor:redhat", "csaf", "Red Hat CSAF", "high", true, DateTimeOffset.UtcNow) + } + }; + + var provider = BuildServiceProvider(backend); + await CommandHandlers.HandleExcititorUpdateProviderAsync( + provider, + "excititor:redhat", + "Red Hat Trusted", + "vendor", + new[] { "https://security.access.redhat.com/data/csaf/v2/" }, + "https://security.access.redhat.com/.well-known/csaf/provider-metadata.json", + null, + 0.95, + new[] { "ABC123" }, + "https://issuer.example", + "https://identity.example/*", + verbose: false, + cancellationToken: CancellationToken.None); + + Assert.Equal(0, Environment.ExitCode); + Assert.Equal("providers/excititor%3Aredhat", backend.LastExcititorRoute); + Assert.Equal(HttpMethod.Put, backend.LastExcititorMethod); + + var payload = Assert.IsAssignableFrom>(backend.LastExcititorPayload); + Assert.Equal("Red Hat Trusted", payload["displayName"]); + Assert.Equal("vendor", payload["kind"]); + var baseUris = Assert.IsAssignableFrom>(payload["baseUris"]); + Assert.Contains("https://security.access.redhat.com/data/csaf/v2/", baseUris); + + var discovery = Assert.IsAssignableFrom>(payload["discovery"]); + Assert.Equal("https://security.access.redhat.com/.well-known/csaf/provider-metadata.json", discovery["wellKnownMetadataUri"]); + + var trust = Assert.IsAssignableFrom>(payload["trust"]); + Assert.Equal(0.95, trust["weight"]); + var fingerprints = Assert.IsAssignableFrom>(trust["pgpFingerprints"]); + Assert.Contains("ABC123", fingerprints); + var cosign = Assert.IsAssignableFrom>(trust["cosign"]); + Assert.Equal("https://issuer.example", cosign["issuer"]); + Assert.Equal("https://identity.example/*", cosign["identityPattern"]); + } + finally + { + Environment.ExitCode = original; + } + } + [Fact] public async Task HandleExcititorVerifyAsync_FailsWithoutArguments() { diff --git a/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs b/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs index ec201bcaa..1e6773103 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs @@ -280,8 +280,8 @@ public sealed class ConfiguredAdvisorySourceService new RemediationStep { Order = 1, - Description = "Configure a GitHub token for GHSA (for example `GITHUB_PAT` or the bound `GhsaOptions.ApiToken` setting).", - CommandType = CommandType.EnvVar + Description = "Use Stored Connector Configuration in the Web UI or `stella db connectors configure ghsa --set apiToken=...` to persist the GitHub token. Environment binding remains a compatibility fallback only.", + CommandType = CommandType.StellaCli }), definition.DocumentationUrl), "cisco" when !HasCiscoCredentials() => @@ -294,8 +294,8 @@ public sealed class ConfiguredAdvisorySourceService new RemediationStep { Order = 1, - Description = "Set `CONCELIER__SOURCES__VNDR__CISCO__CLIENTID` and `CONCELIER__SOURCES__VNDR__CISCO__CLIENTSECRET` for the Cisco PSIRT connector.", - CommandType = CommandType.EnvVar + Description = "Use Stored Connector Configuration in the Web UI or `stella db connectors configure cisco --set clientId=... --set clientSecret=...` to persist Cisco OAuth credentials. Environment binding remains a compatibility fallback only.", + CommandType = CommandType.StellaCli }), definition.DocumentationUrl), "microsoft" when !HasMsrcCredentials() => @@ -308,8 +308,8 @@ public sealed class ConfiguredAdvisorySourceService new RemediationStep { Order = 1, - Description = "Set `CONCELIER__SOURCES__MICROSOFT__TENANTID`, `CONCELIER__SOURCES__MICROSOFT__CLIENTID`, and `CONCELIER__SOURCES__MICROSOFT__CLIENTSECRET` for the MSRC connector (legacy `CONCELIER__SOURCES__VNDR__MSRC__*` is still accepted).", - CommandType = CommandType.EnvVar + Description = "Use Stored Connector Configuration in the Web UI or `stella db connectors configure microsoft --set tenantId=... --set clientId=... --set clientSecret=...` to persist MSRC credentials. Environment binding remains a compatibility fallback only.", + CommandType = CommandType.StellaCli }), definition.DocumentationUrl), "oracle" when !HasOracleUris() => @@ -322,8 +322,8 @@ public sealed class ConfiguredAdvisorySourceService new RemediationStep { Order = 1, - Description = "Configure `CalendarUris` and/or `AdvisoryUris` for the Oracle connector. The documented default is the Oracle security alerts calendar page.", - CommandType = CommandType.EnvVar, + Description = "Use Stored Connector Configuration in the Web UI or `stella db connectors configure oracle --set calendarUris=...` to persist Oracle calendar or advisory URIs. The public Oracle landing page remains the documented default.", + CommandType = CommandType.StellaCli, DocumentationUrl = definition.DocumentationUrl }), definition.DocumentationUrl), diff --git a/src/Concelier/StellaOps.Excititor.WebService/TASKS.md b/src/Concelier/StellaOps.Excititor.WebService/TASKS.md index b5d4bf485..45d03789c 100644 --- a/src/Concelier/StellaOps.Excititor.WebService/TASKS.md +++ b/src/Concelier/StellaOps.Excititor.WebService/TASKS.md @@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | REALPLAN-007-A | DONE | 2026-04-15: Removed the host-level `InMemoryVexAttestationStore` fallback; WebService now relies on the Postgres attestation store from `AddExcititorPersistence`, backed by durable attestation migration proof. | | REALPLAN-007-B | DONE | 2026-04-15: Removed the live `InMemoryGraphOverlayStore` fallback; WebService now binds `IGraphOverlayStore` to `PostgresGraphOverlayStore` and startup migrations create `vex.graph_overlays`. | | REALPLAN-007-C | DONE | 2026-04-20: Removed the live Excititor verification fallback path so enabled signature verification now requires a real IssuerDirectory HTTP endpoint and only uses Valkey when explicitly configured; no process-local issuer/cache fallback remains in the default runtime wiring. | +| CONN-CTRL-01 | DONE | 2026-04-22: Added `/excititor/providers` management endpoints backed by the persisted provider store and connector-state repository, with truthful `ready` / `blocked` / `disabled` / `planned` readiness semantics. | diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts index 1b114b0de..9774d2d53 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.routes.ts @@ -112,6 +112,13 @@ export const integrationHubRoutes: Routes = [ loadComponent: () => import('../integrations/advisory-vex-sources/mirror-client-setup.component').then((m) => m.MirrorClientSetupComponent), }, + { + path: 'advisory-vex-sources/vex-providers', + title: 'VEX Provider Control Plane', + data: { breadcrumb: 'VEX Provider Control Plane' }, + loadComponent: () => + import('../integrations/advisory-vex-sources/vex-provider-catalog.component').then((m) => m.VexProviderCatalogComponent), + }, { path: 'secrets', diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts index 4ccfd0e1a..c0381cb44 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts @@ -18,7 +18,7 @@ import { SourceConfigurationResponse, SourceConfigurationFieldItem, } from './source-management.api'; -import { buildMirrorCommands, getAdvisoryVexNavigationExtras } from './advisory-vex-route-helpers'; +import { buildMirrorCommands, buildVexProviderCommands, getAdvisoryVexNavigationExtras } from './advisory-vex-route-helpers'; const CATEGORY_ORDER = [ 'Primary', @@ -77,6 +77,9 @@ interface EnabledSourceFailureSummary {

Advisory & VEX Source Catalog

Browse, enable, and health-check upstream advisory and VEX data sources.

+ + Manage VEX Providers + + + + +
+ + {{ providers().length }} providers loaded + {{ blockedProviders().length }} blocked + {{ readyProviders().length }} ready +
+ + + + @if (errorMessage()) { + + } + + @if (successMessage()) { + + } + + @if (loading()) { + + } @else { + + + + + + + + + + + + + + @for (provider of sortedProviders(); track provider.id) { + + + + + + + + + + } + +
ProviderKindReadinessEnabledTrustLast IngestedActions
+ +
{{ provider.id }}
+ @if (provider.blockingReason) { +
{{ provider.blockingReason }}
+ } +
{{ provider.kind }} + + {{ provider.readiness }} + + {{ provider.enabled ? 'yes' : 'no' }} +
{{ provider.trustTier || '-' }}
+ @if (provider.trustWeight !== null && provider.trustWeight !== undefined) { +
weight {{ provider.trustWeight }}
+ } +
{{ formatTimestamp(provider.lastIngestedAt) }} +
+ + + +
+
+ } + + @if (selectedProvider(); as provider) { +
+
+
+

{{ provider.displayName }}

+

Persist discovery and trust settings for {{ provider.id }}.

+
+ + Mirror Client Setup + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ Source: {{ provider.source }} + Sync supported: {{ provider.syncSupported ? 'yes' : 'no' }} + Ready for sync: {{ provider.readyForSync ? 'yes' : 'no' }} + Failures: {{ provider.failureCount }} +
+ +
+ + +
+
+ } + + `, + styles: [` + .provider-catalog { + display: grid; + gap: 1rem; + padding: 1.25rem; + } + .catalog-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + flex-wrap: wrap; + } + .catalog-header h1 { + margin: 0 0 0.35rem; + } + .catalog-header p { + margin: 0; + color: var(--color-text-secondary); + max-width: 58rem; + } + .catalog-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; + } + .catalog-meta { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; + } + .include-disabled-toggle { + display: inline-flex; + gap: 0.5rem; + align-items: center; + color: var(--color-text-secondary); + } + .meta-pill, + .fact-pill { + display: inline-flex; + gap: 0.35rem; + align-items: center; + padding: 0.35rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + font-size: 0.8125rem; + } + .provider-table__selected { + outline: 2px solid color-mix(in srgb, var(--color-brand-primary, #1677ff) 30%, transparent); + outline-offset: -2px; + } + .provider-link { + padding: 0; + border: none; + background: none; + color: var(--color-text-link); + font: inherit; + font-weight: 600; + cursor: pointer; + text-align: left; + } + .provider-subtle, + .provider-warning, + .field small { + display: block; + margin-top: 0.25rem; + font-size: 0.75rem; + color: var(--color-text-secondary); + } + .provider-warning { + color: var(--color-warning-700, #9a6700); + } + .row-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + .status-pill { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + text-transform: lowercase; + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + } + .status-pill--ready { + color: var(--color-success-700, #1a7f37); + border-color: color-mix(in srgb, var(--color-success-700, #1a7f37) 35%, var(--color-border-primary)); + } + .status-pill--blocked, + .status-pill--planned { + color: var(--color-warning-700, #9a6700); + border-color: color-mix(in srgb, var(--color-warning-700, #9a6700) 35%, var(--color-border-primary)); + } + .status-pill--disabled { + color: var(--color-text-secondary); + } + .editor-card { + display: grid; + gap: 1rem; + padding: 1rem; + border-radius: 1rem; + border: 1px solid var(--color-border-primary); + background: var(--color-surface-primary); + box-shadow: var(--shadow-sm); + } + .editor-header { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-start; + } + .editor-header h2 { + margin: 0 0 0.35rem; + } + .editor-header p { + margin: 0; + color: var(--color-text-secondary); + } + .editor-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + } + .field { + display: grid; + gap: 0.4rem; + font-size: 0.875rem; + } + .field span { + font-weight: 600; + } + .field input, + .field textarea, + .field-readonly { + width: 100%; + border-radius: 0.75rem; + border: 1px solid var(--color-border-primary); + padding: 0.7rem 0.85rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + font: inherit; + } + .field textarea { + resize: vertical; + } + .field-readonly { + min-height: 2.8rem; + } + .field--wide { + grid-column: 1 / -1; + } + .provider-facts { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + } + .editor-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; + } + @media (max-width: 900px) { + .provider-catalog { + padding: 1rem; + } + .editor-actions { + justify-content: stretch; + } + .editor-actions .btn { + flex: 1 1 12rem; + } + } + `], +}) +export class VexProviderCatalogComponent implements OnInit { + private readonly api = inject(VexProviderManagementApi); + private readonly router = inject(Router); + + readonly providers = signal([]); + readonly loading = signal(false); + readonly includeDisabled = signal(true); + readonly selectedProviderId = signal(null); + readonly draft = signal(this.createEmptyDraft()); + readonly busyProviderId = signal(null); + readonly errorMessage = signal(null); + readonly successMessage = signal(null); + + readonly sortedProviders = computed(() => + [...this.providers()].sort((left, right) => { + const readinessRank = this.readinessRank(left.readiness) - this.readinessRank(right.readiness); + if (readinessRank !== 0) { + return readinessRank; + } + + return left.displayName.localeCompare(right.displayName); + }), + ); + + readonly selectedProvider = computed(() => + this.providers().find((provider) => provider.id === this.selectedProviderId()) ?? null, + ); + + readonly blockedProviders = computed(() => + this.providers().filter((provider) => provider.readiness === 'blocked' || provider.readiness === 'planned'), + ); + + readonly readyProviders = computed(() => + this.providers().filter((provider) => provider.readiness === 'ready'), + ); + + ngOnInit(): void { + this.refresh(); + } + + advisorySourceLink(): string[] { + return buildAdvisoryVexCommands(this.router); + } + + mirrorDashboardLink(): string[] { + return buildMirrorCommands(this.router); + } + + mirrorClientSetupLink(): string[] { + return buildMirrorCommands(this.router, 'client-setup'); + } + + refresh(): void { + this.loading.set(true); + this.errorMessage.set(null); + this.successMessage.set(null); + + this.api.listProviders(this.includeDisabled()).pipe(take(1)).subscribe({ + next: (response) => { + this.providers.set(response.providers); + const selected = this.selectedProviderId(); + if (selected) { + const provider = response.providers.find((item) => item.id === selected); + if (provider) { + this.resetDraft(provider); + } else { + this.selectedProviderId.set(null); + this.draft.set(this.createEmptyDraft()); + } + } + this.loading.set(false); + }, + error: (error) => { + this.loading.set(false); + this.errorMessage.set(this.describeError(error, 'Failed to load VEX providers.')); + }, + }); + } + + toggleIncludeDisabled(event: Event): void { + const checked = (event.target as HTMLInputElement | null)?.checked ?? true; + this.includeDisabled.set(checked); + this.refresh(); + } + + selectProvider(provider: VexProviderListItem): void { + this.selectedProviderId.set(provider.id); + this.resetDraft(provider); + this.successMessage.set(null); + this.errorMessage.set(null); + } + + toggleProvider(provider: VexProviderListItem): void { + this.busyProviderId.set(provider.id); + this.errorMessage.set(null); + this.successMessage.set(null); + + const request = provider.enabled + ? this.api.disableProvider(provider.id) + : this.api.enableProvider(provider.id); + + request.pipe(take(1)).subscribe({ + next: () => { + this.successMessage.set(`${provider.displayName} is now ${provider.enabled ? 'disabled' : 'enabled'}.`); + this.busyProviderId.set(null); + this.refresh(); + }, + error: (error) => { + this.busyProviderId.set(null); + this.errorMessage.set(this.describeError(error, `Failed to update ${provider.displayName}.`)); + }, + }); + } + + runProvider(provider: VexProviderListItem): void { + this.busyProviderId.set(provider.id); + this.errorMessage.set(null); + this.successMessage.set(null); + + this.api.runProvider(provider.id, { force: false }).pipe(take(1)).subscribe({ + next: () => { + this.successMessage.set(`Run request accepted for ${provider.displayName}.`); + this.busyProviderId.set(null); + this.refresh(); + }, + error: (error) => { + this.busyProviderId.set(null); + this.errorMessage.set(this.describeError(error, `Failed to run ${provider.displayName}.`)); + }, + }); + } + + saveProvider(provider: VexProviderListItem): void { + const request = this.buildUpdateRequest(); + this.busyProviderId.set(provider.id); + this.errorMessage.set(null); + this.successMessage.set(null); + + this.api.updateProvider(provider.id, request).pipe(take(1)).subscribe({ + next: () => { + this.successMessage.set(`Saved provider settings for ${provider.displayName}.`); + this.busyProviderId.set(null); + this.refresh(); + }, + error: (error) => { + this.busyProviderId.set(null); + this.errorMessage.set(this.describeError(error, `Failed to save ${provider.displayName}.`)); + }, + }); + } + + resetDraft(provider: VexProviderListItem): void { + this.draft.set({ + displayName: provider.displayName ?? '', + kind: provider.kind ?? '', + baseUris: provider.baseUris.join('\n'), + wellKnownMetadataUri: provider.wellKnownMetadataUri ?? '', + rolieServiceUri: provider.rolieServiceUri ?? '', + trustWeight: provider.trustWeight === null || provider.trustWeight === undefined ? '' : String(provider.trustWeight), + pgpFingerprints: provider.pgpFingerprints.join('\n'), + cosignIssuer: provider.cosignIssuer ?? '', + cosignIdentityPattern: provider.cosignIdentityPattern ?? '', + }); + } + + updateDraft(field: keyof ProviderDraft, event: Event): void { + const value = (event.target as HTMLInputElement | HTMLTextAreaElement | null)?.value ?? ''; + this.draft.update((draft) => ({ ...draft, [field]: value })); + } + + formatTimestamp(value?: string | null): string { + if (!value) { + return 'Never'; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return parsed.toLocaleString(); + } + + private buildUpdateRequest(): VexProviderUpdateRequest { + const draft = this.draft(); + const trustWeight = draft.trustWeight.trim(); + + return { + displayName: draft.displayName.trim(), + kind: draft.kind.trim(), + baseUris: this.toLines(draft.baseUris), + discovery: { + wellKnownMetadataUri: this.nullIfEmpty(draft.wellKnownMetadataUri), + rolieServiceUri: this.nullIfEmpty(draft.rolieServiceUri), + }, + trust: { + weight: trustWeight.length > 0 ? Number(trustWeight) : null, + pgpFingerprints: this.toLines(draft.pgpFingerprints), + cosign: { + issuer: this.nullIfEmpty(draft.cosignIssuer), + identityPattern: this.nullIfEmpty(draft.cosignIdentityPattern), + }, + }, + }; + } + + private toLines(value: string): string[] { + return value + .split(/\r?\n/) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + private nullIfEmpty(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + private readinessRank(readiness: string): number { + switch (readiness) { + case 'ready': + return 0; + case 'blocked': + return 1; + case 'planned': + return 2; + case 'disabled': + return 3; + default: + return 4; + } + } + + private createEmptyDraft(): ProviderDraft { + return { + displayName: '', + kind: '', + baseUris: '', + wellKnownMetadataUri: '', + rolieServiceUri: '', + trustWeight: '', + pgpFingerprints: '', + cosignIssuer: '', + cosignIdentityPattern: '', + }; + } + + private describeError(error: unknown, fallback: string): string { + if (typeof error === 'object' && error && 'error' in error) { + const payload = (error as { error?: { detail?: string; error?: string; message?: string } }).error; + if (payload?.detail) { + return payload.detail; + } + if (payload?.message) { + return payload.message; + } + if (payload?.error) { + return payload.error; + } + } + + if (typeof error === 'object' && error && 'message' in error && typeof (error as { message?: string }).message === 'string') { + return (error as { message: string }).message; + } + + return fallback; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-management.api.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-management.api.ts new file mode 100644 index 000000000..b550b0411 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/vex-provider-management.api.ts @@ -0,0 +1,122 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { AuthSessionStore } from '../../../core/auth/auth-session.store'; +import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers'; + +export interface VexProviderListItem { + id: string; + displayName: string; + kind: string; + readiness: string; + syncState: string; + enabled: boolean; + defaultEnabled: boolean; + syncSupported: boolean; + readyForSync: boolean; + configurationSupported: boolean; + source: string; + trustTier: string; + description?: string | null; + blockingReasonCode?: string | null; + blockingReason?: string | null; + lastFailureReason?: string | null; + wellKnownMetadataUri?: string | null; + rolieServiceUri?: string | null; + trustWeight?: number | null; + baseUris: string[]; + pgpFingerprints: string[]; + cosignIssuer?: string | null; + cosignIdentityPattern?: string | null; + failureCount: number; + lastIngestedAt?: string | null; + lastUpdatedAt?: string | null; + nextEligibleRunAt?: string | null; +} + +export interface VexProviderListResponse { + providers: VexProviderListItem[]; + totalCount: number; +} + +export interface VexProviderUpdateRequest { + displayName?: string | null; + kind?: string | null; + baseUris?: string[] | null; + discovery?: { + wellKnownMetadataUri?: string | null; + rolieServiceUri?: string | null; + } | null; + trust?: { + weight?: number | null; + pgpFingerprints?: string[] | null; + cosign?: { + issuer?: string | null; + identityPattern?: string | null; + } | null; + } | null; +} + +export interface VexProviderRunRequest { + since?: string | null; + window?: string | null; + force?: boolean | null; +} + +@Injectable({ providedIn: 'root' }) +export class VexProviderManagementApi { + private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + private readonly baseUrl = '/api/v1/excititor/providers'; + + listProviders(includeDisabled = true): Observable { + return this.http.get( + `${this.baseUrl}?includeDisabled=${includeDisabled}`, + { headers: this.buildHeaders() }, + ); + } + + enableProvider(providerId: string): Observable { + return this.http.post( + `${this.baseUrl}/${encodeURIComponent(providerId)}/enable`, + null, + { headers: this.buildHeaders() }, + ); + } + + disableProvider(providerId: string): Observable { + return this.http.post( + `${this.baseUrl}/${encodeURIComponent(providerId)}/disable`, + null, + { headers: this.buildHeaders() }, + ); + } + + runProvider(providerId: string, request: VexProviderRunRequest): Observable { + return this.http.post( + `${this.baseUrl}/${encodeURIComponent(providerId)}/run`, + request, + { headers: this.buildHeaders() }, + ); + } + + updateProvider(providerId: string, request: VexProviderUpdateRequest): Observable { + return this.http.put( + `${this.baseUrl}/${encodeURIComponent(providerId)}`, + request, + { headers: this.buildHeaders() }, + ); + } + + private buildHeaders(): HttpHeaders { + const tenantId = this.authSession.getActiveTenantId(); + if (!tenantId) { + return new HttpHeaders(); + } + + return new HttpHeaders({ + [StellaOpsHeaders.Tenant]: tenantId, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts index 85a7ddc63..39ce73866 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts @@ -283,7 +283,7 @@ export class SecurityDispositionPageComponent { readonly quickLinks: readonly StellaQuickLink[] = [ { label: 'Advisory Feeds', route: '/ops/integrations/advisory-vex-sources', description: 'Configure NVD, OSV, and GHSA advisory sources' }, - { label: 'VEX Sources', route: '/ops/integrations/advisory-vex-sources', description: 'Manage VEX statement providers and trust' }, + { label: 'VEX Sources', route: '/ops/integrations/advisory-vex-sources/vex-providers', description: 'Manage VEX statement providers and trust' }, ]; readonly loading = signal(false);