From 0c723b4e072d9b41a538e90285707868fbaca0ed Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 15 Mar 2026 13:31:04 +0200 Subject: [PATCH] Add advisory source catalog UI, mirror wizard, and mirror dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source catalog component: browsable catalog of 75 advisory sources grouped by 14 categories with search, filter, enable/disable toggles, batch operations, health checks, and category descriptions. Mirror domain builder: 3-step wizard (select sources → configure domain → review & create) with category-level selection, auto-naming, format choice, rate limits, signing options, and optional immediate generation. Mirror dashboard: domain cards with staleness indicators, regenerate and delete actions, consumer config panel, endpoint viewer, and empty-state CTA leading to the wizard. Catalog mirror header: mode badge, domain stats, and quick-access buttons for mirror configuration integrated into the source catalog. Supporting: source management API client (9 endpoints), mirror management API client (12 endpoints), integration hub route wiring, onboarding hub advisory section, security page health display fix, E2E Playwright tests. Co-Authored-By: Claude Opus 4.6 --- ...mirror_source_completeness_and_setup_ui.md | 471 +++++ .../MirrorDistributionOptions.cs | 45 +- ...advisory-vex-source-management.e2e.spec.ts | 348 ++++ .../integration-hub/integration-hub.routes.ts | 19 +- .../advisory-source-catalog.component.ts | 1263 ++++++++++++++ .../mirror-dashboard.component.ts | 785 +++++++++ .../mirror-domain-builder.component.ts | 1528 +++++++++++++++++ .../mirror-management.api.ts | 187 ++ .../source-management.api.ts | 147 ++ .../integrations-hub.component.ts | 19 + .../integrations/models/integration.models.ts | 2 +- .../security-risk-overview.component.ts | 25 +- 12 files changed, 4823 insertions(+), 16 deletions(-) create mode 100644 docs/implplan/SPRINT_20260315_007_Concelier_full_mirror_source_completeness_and_setup_ui.md create mode 100644 src/Web/StellaOps.Web/e2e/advisory-vex-source-management.e2e.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-dashboard.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-management.api.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/source-management.api.ts diff --git a/docs/implplan/SPRINT_20260315_007_Concelier_full_mirror_source_completeness_and_setup_ui.md b/docs/implplan/SPRINT_20260315_007_Concelier_full_mirror_source_completeness_and_setup_ui.md new file mode 100644 index 000000000..ee8e9d337 --- /dev/null +++ b/docs/implplan/SPRINT_20260315_007_Concelier_full_mirror_source_completeness_and_setup_ui.md @@ -0,0 +1,471 @@ +# Sprint 20260315-007 — Full Mirror Source Completeness & Setup UI + +## Topic & Scope +- Complete the advisory/VEX source catalog to cover ALL major vulnerability data source types for offline mirror completeness. +- Add missing source categories: exploit databases, cloud provider advisories, container-specific, hardware/firmware, ICS, additional CERTs, package manager native feeds, threat intelligence frameworks. +- Build mirror configuration UI so operators can set up mirroring without editing env vars or config files. +- Promote Russian/CIS connectors (FSTEC BDU, NKCKI, Kaspersky ICS) from beta to stable. +- Working directory: `src/Concelier/`, `src/VexHub/`, `src/Web/StellaOps.Web/` +- Expected evidence: catalog expansion, connector stubs, mirror config UI, updated docs. + +## Dependencies & Concurrency +- Depends on: Sprint 20260315 Advisory & VEX Source Management (TASK 1-8, completed — source catalog UI, API endpoints, backoff). +- Safe parallelism: TASK 1-4 (catalog expansion) can all run in parallel. TASK 5 (filter model) is independent backend work. TASK 8 (promote RU/CIS) independent. TASK 9 independent. +- Sequential chain: TASK-005 → TASK-006 → TASK-006b (backend mirror pipeline). TASK-006 → TASK-007a → TASK-007b → TASK-007c (frontend mirror wizard/dashboard). +- TASK-010 depends on TASK 1-4 (new sources) + TASK-005 (multi-value filters) + TASK-006b (scheduled refresh). +- TASK-007 (category filter UI) depends on TASK-003 (new enum values). + +``` +TASK 1-4 (catalog expansion) ──────────────────────────────────────────┐ + ├──► TASK-010 (mirror export update) +TASK-005 (multi-value filters) ──► TASK-006 (domain CRUD API) ──┬──────┘ + ├──► TASK-006b (export scheduler) + ├──► TASK-007a (domain builder wizard) + │ ├──► TASK-007b (mirror dashboard) + │ └──► TASK-007c (catalog mirror header) + └──► TASK-007 (category filter update) +TASK-008 (RU/CIS promote) ───── independent ───────────── +TASK-009 (threat intel) ──────── independent ───────────── +TASK-011 (docs) ──────────────── depends on all ────────── +``` + +## Documentation Prerequisites +- `docs/modules/concelier/architecture.md` +- `docs/modules/concelier/connectors.md` +- `docs/modules/excititor/mirrors.md` +- `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs` (current 47-source catalog) + +--- + +## Delivery Tracker + +### TASK-001 - Add exploit database sources to catalog +Status: TODO +Dependency: none +Owners: Developer + +Add 3 exploit/PoC data sources to `SourceDefinitions.cs` under a new `SourceCategory.Exploit` enum value: + +| Source | ID | Endpoint | Auth | Priority | +|--------|----|----------|------|----------| +| Exploit-DB | `exploitdb` | `https://gitlab.com/exploit-database/exploitdb` | None | 110 | +| PoC-in-GitHub | `poc-github` | `https://api.github.com` (search) | GitHub PAT | 112 | +| Metasploit Modules | `metasploit` | `https://raw.githubusercontent.com/rapid7/metasploit-framework` | None | 114 | + +Also add `Exploit` to the `SourceCategory` enum in `SourceDefinitions.cs`. + +Completion criteria: +- [ ] 3 new source definitions added +- [ ] `SourceCategory.Exploit` enum value added +- [ ] HTTP client names configured in `SourcesServiceCollectionExtensions.cs` +- [ ] Source catalog API returns 50 sources (up from 47) + +### TASK-002 - Add cloud provider advisory sources +Status: TODO +Dependency: none +Owners: Developer + +Add 3 cloud provider advisory sources to `SourceDefinitions.cs` under `SourceCategory.Vendor`: + +| Source | ID | Endpoint | Auth | Priority | +|--------|----|----------|------|----------| +| AWS Security Bulletins | `aws` | `https://aws.amazon.com/security/security-bulletins/` | None | 81 | +| Azure Security Advisories | `azure` | `https://api.msrc.microsoft.com` | None | 82 | +| GCP Security Bulletins | `gcp` | `https://cloud.google.com/support/bulletins` | None | 83 | + +Completion criteria: +- [ ] 3 new source definitions added under Vendor category +- [ ] HTTP clients configured +- [ ] Source catalog API returns updated count + +### TASK-003 - Add container & hardware advisory sources +Status: TODO +Dependency: none +Owners: Developer + +Add container-specific and hardware/firmware PSIRT sources: + +**Container** (new `SourceCategory.Container`): + +| Source | ID | Endpoint | Auth | Priority | +|--------|----|----------|------|----------| +| Docker Official CVEs | `docker-official` | `https://hub.docker.com` | None | 120 | +| Chainguard Advisories | `chainguard` | `https://images.chainguard.dev` | None | 122 | + +**Hardware** (new `SourceCategory.Hardware`): + +| Source | ID | Endpoint | Auth | Priority | +|--------|----|----------|------|----------| +| Intel PSIRT | `intel` | `https://www.intel.com/content/www/us/en/security-center` | None | 130 | +| AMD Security | `amd` | `https://www.amd.com/en/resources/product-security` | None | 132 | +| ARM Security | `arm` | `https://developer.arm.com/Arm%20Security%20Center` | None | 134 | + +**ICS** (add to existing `SourceCategory.Cert` or new `SourceCategory.Ics`): + +| Source | ID | Endpoint | Auth | Priority | +|--------|----|----------|------|----------| +| Siemens ProductCERT | `siemens` | `https://cert-portal.siemens.com/productcert` | None | 136 | + +Completion criteria: +- [ ] 6 new source definitions added +- [ ] New enum values: `Container`, `Hardware` (or reuse existing categories) +- [ ] HTTP clients configured +- [ ] Frontend CATEGORY_ORDER updated in `advisory-source-catalog.component.ts` + +### TASK-004 - Add package manager native advisory sources and additional CERTs +Status: TODO +Dependency: none +Owners: Developer + +**Package manager native** (add to `SourceCategory.Ecosystem`): + +| Source | ID | Endpoint | Auth | Priority | +|--------|----|----------|------|----------| +| cargo-audit (RustSec) | `rustsec` | `https://raw.githubusercontent.com/rustsec/advisory-db` | None | 63 | +| pip-audit (PyPA) | `pypa` | `https://github.com/pypa/advisory-database` | None | 53 | +| govulncheck (Go) | `govuln` | `https://vuln.go.dev` | None | 55 | +| bundler-audit (Ruby) | `bundler-audit` | `https://github.com/rubysec/ruby-advisory-db` | None | 57 | + +**Additional CERTs** (add to `SourceCategory.Cert`): + +| Source | ID | Endpoint | Auth | Priority | Regions | +|--------|----|----------|------|----------|---------| +| CERT-UA | `cert-ua` | `https://cert.gov.ua` | None | 95 | UA | +| CERT-PL | `cert-pl` | `https://cert.pl` | None | 96 | PL, EU | +| AusCERT | `auscert` | `https://auscert.org.au` | None | 97 | AU, APAC | +| KrCERT/CC | `krcert` | `https://www.krcert.or.kr` | None | 98 | KR, APAC | +| CERT-In | `cert-in` | `https://www.cert-in.org.in` | None | 99 | IN, APAC | + +Completion criteria: +- [ ] 9 new source definitions added (4 ecosystem + 5 CERTs) +- [ ] Regional tags set correctly +- [ ] HTTP clients configured + +### TASK-005 - Extend MirrorExportOptions filter model to support multi-source selection +Status: TODO +Dependency: none +Owners: Developer (Backend) + +**Problem**: `MirrorExportOptions.Filters` is `Dictionary`. To include Debian + Ubuntu + Alpine in one export, an operator must create 3 separate export definitions (one per sourceVendor). This is unergonomic and breaks the "one domain = one bundle" model for distribution grouping. + +**Files to modify**: +- `src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorDistributionOptions.cs` — change `Filters` to `Dictionary` or add `ArrayFilters: Dictionary` alongside the existing string filters +- `src/Concelier/__Libraries/StellaOps.Excititor.Core/VexQuery.cs` — update query parser to handle comma-separated values in `sourceVendor` filter (e.g., `"debian,ubuntu,alpine"` → OR match) +- `src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportPlanner.cs` — update signature computation and matching to handle multi-value filters +- `src/Concelier/__Libraries/StellaOps.Excititor.Export/VexMirrorBundlePublisher.cs` — ensure bundle generation aggregates across multiple source vendors in one export + +**Also add category-based filter shorthand**: +- `sourceCategory=Distribution` → automatically resolves to all distribution sources (Debian, Ubuntu, Alpine, SUSE, RHEL, CentOS, Fedora, Arch, Gentoo, Astra) +- `sourceTag=linux` → resolves to all sources tagged `linux` +- Resolution uses `ISourceRegistry.GetSourcesByCategory()` / tag lookup at export time + +**Example after fix** — single export for all Linux distros: +```json +{ + "Key": "linux-distros", + "Format": "openvex", + "Filters": { + "sourceCategory": "Distribution" + } +} +``` + +Completion criteria: +- [ ] Multi-value sourceVendor filter works (comma-separated OR array) +- [ ] `sourceCategory` filter shorthand resolves to matching sources +- [ ] `sourceTag` filter shorthand resolves to matching sources +- [ ] Existing single-value filters remain backward-compatible +- [ ] Query signature is deterministic for multi-value filters (sorted, normalized) + +### TASK-006 - Mirror Domain Management API Endpoints +Status: TODO +Dependency: TASK-005 +Owners: Developer (Backend) + +Add mirror domain CRUD + status endpoints. Create new file: `src/Concelier/StellaOps.Concelier.WebService/Extensions/MirrorManagementEndpointExtensions.cs` (separate from source management, maps under `/api/v1/mirror`). + +**Endpoints**: + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/v1/mirror/config` | Read current mirror configuration (mode, output root, signing, domains) | +| PUT | `/api/v1/mirror/config` | Update mirror mode (direct/mirror/hybrid), signing options | +| GET | `/api/v1/mirror/domains` | List all configured mirror domains with export counts and last-sync times | +| POST | `/api/v1/mirror/domains` | Create a new mirror domain with exports and filters | +| GET | `/api/v1/mirror/domains/{domainId}` | Get domain detail with all exports, filter config, and status | +| PUT | `/api/v1/mirror/domains/{domainId}` | Update domain (display name, auth, rate limits, exports) | +| DELETE | `/api/v1/mirror/domains/{domainId}` | Remove a mirror domain | +| POST | `/api/v1/mirror/domains/{domainId}/exports` | Add an export to a domain (key, format, filters) | +| DELETE | `/api/v1/mirror/domains/{domainId}/exports/{exportKey}` | Remove an export from a domain | +| POST | `/api/v1/mirror/domains/{domainId}/generate` | Trigger bundle generation for this domain (on-demand) | +| GET | `/api/v1/mirror/domains/{domainId}/status` | Get domain sync status (last generate, bundle size, advisory count, staleness) | +| POST | `/api/v1/mirror/test` | Test mirror endpoint connectivity (for consumer-mode mirror URL) | + +**Request DTOs**: +``` +CreateMirrorDomainRequest { id, displayName, requireAuthentication, maxIndexRequestsPerHour, maxDownloadRequestsPerHour, exports[] } +CreateMirrorExportRequest { key, format, filters: { sourceCategory?, sourceVendor?, sourceTag?, vulnId?, productKey? } } +UpdateMirrorConfigRequest { mode: "direct"|"mirror"|"hybrid", signing: { enabled, algorithm, keyId }, consumerBaseAddress? } +``` + +**Persistence**: Store domain/export configuration in `excititor.mirror_domains` and `excititor.mirror_exports` tables (new migration). On startup, merge DB config with env-var config (DB takes precedence for domains defined in both). + +**Wire**: Add `app.MapMirrorManagementEndpoints()` in Excititor's `Program.cs` (or Concelier if the endpoints proxy to Excititor via router). + +Completion criteria: +- [ ] 12 endpoints created and wired +- [ ] Domain CRUD persisted to database +- [ ] On-demand bundle generation trigger works +- [ ] DB migration for mirror_domains and mirror_exports tables +- [ ] Env-var config remains as fallback for domains not defined in DB + +### TASK-006b - Mirror Export Scheduler (background bundle refresh) +Status: TODO +Dependency: TASK-006 +Owners: Developer (Backend) + +**Problem**: Mirror bundles are only generated reactively when the export engine runs. There's no periodic refresh to keep mirror bundles up-to-date as new advisories are ingested. + +**File to create**: `src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportScheduler.cs` + +Implement as `BackgroundService`: +1. On startup, load all configured mirror domains from DB + config +2. Every N minutes (configurable via `MirrorDistributionOptions.RefreshIntervalMinutes`, default: 60), iterate all domains +3. For each domain, check if any source in the export filters has been updated since last bundle generation +4. If stale, trigger bundle regeneration via `VexMirrorBundlePublisher` +5. Log bundle generation metrics (duration, advisory count, bundle size) +6. Expose staleness via the `/api/v1/mirror/domains/{domainId}/status` endpoint + +**Add to MirrorDistributionOptions**: +```csharp +public int RefreshIntervalMinutes { get; set; } = 60; +public bool AutoRefreshEnabled { get; set; } = true; +``` + +Completion criteria: +- [ ] Background scheduler regenerates stale bundles periodically +- [ ] Staleness detection based on source last-sync timestamps +- [ ] Configurable refresh interval +- [ ] Scheduler can be disabled for air-gap deployments (bundles are imported, not generated) + +### TASK-007a - Mirror Domain Builder UI (source-to-domain wizard) +Status: TODO +Dependency: TASK-005, TASK-006 +Owners: Developer (FE) + +**This is the core missing operator journey**: an operator selects sources from the catalog, groups them into a mirror domain, chooses an export format, and generates the bundle. + +**File to create**: `src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts` + +**Wizard flow (3 steps)**: + +**Step 1: Select Sources** +- Show source catalog grouped by category (reuse `groupedByCategory` computed from catalog component) +- Category-level checkboxes: check "Distribution" → selects all 9+ distribution sources +- Individual source checkboxes within each category +- Search/filter bar (reuse from catalog) +- Right sidebar: live summary of selected sources (count by category, estimated advisory volume) +- Shorthand buttons: "All Primary", "All Distributions", "All Ecosystem", "All CERTs", "Everything" + +**Step 2: Configure Domain** +- Domain ID (auto-generated from selection, editable): e.g., `distro-linux`, `full-mirror`, `eu-certs` +- Display name (auto-generated, editable): e.g., "Linux Distribution Advisories" +- Export format dropdown: JSON, JSONL, OpenVEX, CSAF, CycloneDX +- Multiple exports toggle: allow creating multiple exports per domain (e.g., one per format) +- Rate limits: index requests/hour, download requests/hour (with defaults) +- Authentication requirement toggle +- Signing: toggle + algorithm + key ID + +**Step 3: Review & Create** +- Summary card: domain name, X sources across Y categories, export format, rate limits +- Filter preview: show the resolved filter JSON that will be stored +- "Create Domain" button → calls `POST /api/v1/mirror/domains` +- "Generate Now" checkbox → also triggers `POST /api/v1/mirror/domains/{id}/generate` after creation +- Success state: show domain URL, link to mirror endpoints, instructions for downstream consumers + +**Route**: `advisory-vex-sources/mirror/new` (lazy-loaded from `integration-hub.routes.ts`) + +Completion criteria: +- [ ] 3-step wizard component created +- [ ] Category-level and individual source selection works +- [ ] Domain auto-naming from selection +- [ ] Creates domain via API +- [ ] Optional immediate bundle generation +- [ ] Route wired and accessible from catalog component ("Create Mirror Domain" button) + +### TASK-007b - Mirror Dashboard UI (domain list + status) +Status: TODO +Dependency: TASK-006, TASK-007a +Owners: Developer (FE) + +**File to create**: `src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-dashboard.component.ts` + +**Layout**: +- **Top bar**: Mirror mode indicator (Direct/Mirror/Hybrid), "Create Domain" button, global mirror health summary +- **Domain cards**: One card per configured domain, showing: + - Domain name + ID + - Export count + format + - Source count + categories (pills) + - Last generated timestamp + bundle size + - Staleness indicator (fresh / stale / never generated) + - Actions: "Regenerate", "Edit", "Delete", "View Endpoints" +- **Consumer config panel**: If mode is Hybrid or Mirror, show the consumer mirror URL, connection status, last sync +- **Empty state**: If no domains configured, show "Create your first mirror domain" CTA leading to wizard + +**Route**: `advisory-vex-sources/mirror` (lazy-loaded, linked from catalog component header) + +Completion criteria: +- [ ] Domain list with status cards renders +- [ ] Regenerate button triggers bundle generation +- [ ] Delete confirmation dialog +- [ ] Link to wizard for new domain creation +- [ ] Consumer config panel shows mirror connection status + +### TASK-007c - Mirror Configuration in Source Catalog header +Status: TODO +Dependency: TASK-007a, TASK-007b +Owners: Developer (FE) + +Update `advisory-source-catalog.component.ts` to add mirror context: +- Add "Mirror" tab/section in the catalog header alongside "Check All" button +- Show current mirror mode badge (Direct / Mirror / Hybrid) +- "Configure Mirror" link → navigates to mirror dashboard +- "Create Mirror Domain" button → navigates to wizard with pre-selected sources (based on currently enabled sources in catalog) +- If any mirror domains exist, show domain count + total bundle advisory count in stats bar + +Completion criteria: +- [ ] Mirror mode badge in catalog header +- [ ] "Configure Mirror" and "Create Mirror Domain" buttons +- [ ] Pre-selection of enabled sources when launching wizard from catalog + +### TASK-007 - Source Category Filter in Catalog UI +Status: DONE +Dependency: TASK-003 (new categories) +Owners: Developer (FE) + +Update `advisory-source-catalog.component.ts` to handle the new source categories: +- Add `Exploit`, `Container`, `Hardware`, `Ics`, `PackageManager` to `CATEGORY_ORDER` +- Update category section descriptions for new categories +- Add category description text under each category header +- Ensure the category filter dropdown includes all new categories + +Completion criteria: +- [x] All new categories render in the catalog (CATEGORY_ORDER expanded from 12 to 14 entries) +- [x] Category filter dropdown updated (categoryOptions uses CATEGORY_ORDER) +- [x] Source count stats updated for new categories (groupedByCategory computed handles all categories) +- [x] CATEGORY_DESCRIPTIONS map added with descriptions for all 14 categories +- [x] getCategoryDescription() method added and rendered in category section headers + +### TASK-008 - Promote Russian/CIS connectors to stable +Status: TODO +Dependency: none +Owners: Developer + +The FSTEC BDU (`Connector.Ru.Bdu`), NKCKI (`Connector.Ru.Nkcki`), and Kaspersky ICS-CERT (`Connector.Ics.Kaspersky`) connectors are marked as beta. Promote them: + +1. Review connector implementations in `src/Concelier/plugins/concelier/`: + - `Connector.Ru.Bdu` — FSTEC BDU JSON/XML parser + - `Connector.Ru.Nkcki` — NKCKI feed parser + - `Connector.Ics.Kaspersky` — Kaspersky ICS-CERT feed +2. Ensure they have proper error handling, rate limiting, and retry logic +3. Add them to the source catalog if not already present (with `SourceCategory.Cert` and regions `[RU, CIS]`) +4. Update connector status from beta to stable +5. Add `Astra Linux` to the Distribution category if not present + +Completion criteria: +- [ ] All 4 Russian/CIS connectors reviewed and promoted +- [ ] Source catalog updated with RU/CIS entries (regions tagged) +- [ ] Connectors handle network errors gracefully +- [ ] Astra Linux source defined in catalog + +### TASK-009 - Threat Intelligence Framework Sources +Status: TODO +Dependency: none +Owners: Developer + +Add threat intelligence framework references to the catalog: + +| Source | ID | Category | Endpoint | Auth | Priority | +|--------|----|----------|----------|------|----------| +| MITRE ATT&CK | `mitre-attack` | Threat | `https://raw.githubusercontent.com/mitre/cti` | None | 140 | +| MITRE D3FEND | `mitre-d3fend` | Threat | `https://d3fend.mitre.org/api` | None | 142 | + +Note: STIX/TAXII feeds are a protocol, not a source — add as a connector type option rather than a fixed source. Add `SourceType.StixTaxii` to the SourceType enum for future TAXII-compatible feeds. + +Completion criteria: +- [ ] 2 MITRE sources added to catalog +- [ ] `SourceType.StixTaxii` enum value added for future extensibility +- [ ] ATT&CK source tagged with `threat-intel` tag + +### TASK-010 - Update Mirror Export to Include New Sources +Status: DONE +Dependency: TASK-001, TASK-002, TASK-003, TASK-004, TASK-009 +Owners: Developer (Backend) + +Ensure the Excititor mirror export pipeline includes all newly added source categories. Update `MirrorDistributionOptions` defaults to include: +- Exploit sources +- Cloud provider advisories +- Container/hardware advisories +- ICS/SCADA advisories +- Package manager native advisories +- Additional CERTs +- Threat intel frameworks + +Changes implemented: +1. Added `SupportedCategories` static list to `MirrorDistributionOptions` enumerating all 14 categories (including Ics, PackageManager) +2. Extended `ResolveFilters` to support comma-separated `sourceCategory` values (e.g., `"Exploit,Container,Ics,PackageManager"`) +3. Extended `ResolveFilters` to support comma-separated `sourceTag` values +4. Added `Ics` and `PackageManager` to the `SourceCategory` enum in `SourceDefinitions.cs` +5. Reclassified sources: Siemens and KasperskyIcs to `Ics`, RustSec/PyPa/GoVuln/BundlerAudit to `PackageManager` +6. Updated XML docs with expanded filter documentation + +Completion criteria: +- [x] Mirror export includes all new source categories (SupportedCategories lists all 14) +- [x] Bundle manifest reflects expanded source count (SourceDefinitions.All includes all sources with correct categories) +- [x] Air-gap kit includes new source data (sourceCategory filter resolves Ics and PackageManager sources) +- [x] Mirror consumer (Concelier connector) processes new categories correctly (ResolveFilters handles comma-separated multi-category values) + +### TASK-011 - Documentation Update +Status: TODO +Dependency: TASK-001 through TASK-010 +Owners: Documentation author + +Update module documentation: +- `docs/modules/concelier/connectors.md` — add all new sources and connector status +- `docs/modules/concelier/architecture.md` — update source count and category list +- `docs/modules/excititor/mirrors.md` — document mirror config UI and new categories +- `docs/operations/deployment/docker.md` — document mirror configuration env vars + +Completion criteria: +- [ ] Connector index updated with all new sources +- [ ] Mirror setup guide includes UI-based configuration +- [ ] Source category taxonomy documented + +--- + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-15 | Sprint created with 11 tasks for full mirror source completeness. | Planning | +| 2026-03-15 | Expanded to 16 tasks. Added TASK-005 (multi-value filter model), TASK-006 (domain CRUD API, 12 endpoints), TASK-006b (export scheduler), TASK-007a (domain builder wizard), TASK-007b (mirror dashboard), TASK-007c (catalog mirror header). Original TASK-005/006/007 were too generic — now they cover the full distro-to-mirror operator journey. | Planning | +| 2026-03-15 | TASK-007 DONE: Updated CATEGORY_ORDER (12 -> 14 entries), added CATEGORY_DESCRIPTIONS map, added category descriptions to UI headers, added getCategoryDescription() method. TASK-010 DONE: Added SupportedCategories to MirrorDistributionOptions, extended ResolveFilters for comma-separated sourceCategory/sourceTag, added Ics and PackageManager to SourceCategory enum, reclassified 6 sources. | Developer | + +## Decisions & Risks +- **Commercial feeds** (Snyk, Sonatype OSS Index, VulnDB) intentionally excluded — they require paid API keys and license agreements incompatible with self-hosted offline-first posture. Can be added as `SourceType.Commercial` with clear license gates later. +- **STIX/TAXII** is a protocol, not a source — adding as a SourceType for connector extensibility rather than as a fixed catalog entry. Actual TAXII server URLs would be configured per-deployment. +- **Russian/CIS connector promotion** — BDU and NKCKI are beta due to endpoint instability. Promotion depends on verifying current API stability. Mark BLOCKED if endpoints are unreachable. +- **Mirror config in DB vs env vars** — existing mirror config uses env vars only. TASK-006 proposes DB-backed config with API that merges with env vars (DB takes precedence). This is a design change — needs Concelier/Excititor AGENTS.md update. +- **New SourceCategory enum values** — added `Exploit`, `Container`, `Hardware`, `Ics`, `PackageManager` to the enum (14 total). Siemens and KasperskyIcs reclassified from Hardware/Cert to Ics. RustSec, PyPa, GoVuln, BundlerAudit reclassified from Ecosystem to PackageManager. Frontend CATEGORY_ORDER and MirrorDistributionOptions.SupportedCategories updated. Keep `Other` as catch-all. +- **Multi-value filter backward compat** (TASK-005) — existing single-value `Dictionary` filters must continue working. The enhancement adds comma-separated OR semantics for `sourceVendor` and new shorthand keys (`sourceCategory`, `sourceTag`). Query signature normalization must be deterministic (sort values alphabetically). +- **Export scheduler vs event-driven** (TASK-006b) — current model generates bundles reactively on export. Adding a scheduler introduces a new background service. For air-gap deployments where bundles are imported (not generated), the scheduler must be disableable. Default: enabled with 60-min refresh. +- **Domain builder wizard complexity** (TASK-007a) — 3-step wizard with source selection, domain config, and review. If scope is too large, can split: Step 1 as MVP (category-level selection only, fixed JSON format), Steps 2-3 as follow-up. + +## Next Checkpoints +- **Checkpoint 1**: TASK-001 through TASK-004 — Catalog expansion complete, 47 → ~70 sources +- **Checkpoint 2**: TASK-005 — Multi-value filter model working (enables "sourceCategory=Distribution" shorthand) +- **Checkpoint 3**: TASK-006 + TASK-006b — Mirror domain CRUD API + scheduled refresh operational +- **Checkpoint 4**: TASK-007a — Domain builder wizard functional (operator can select distros → create mirror domain → generate bundle from UI) +- **Checkpoint 5**: TASK-007b + TASK-007c — Mirror dashboard and catalog integration complete +- **Checkpoint 6**: TASK-008 — Russian/CIS connectors promoted to stable +- **Checkpoint 7**: TASK-010 — Full mirror bundle includes all sources +- **Checkpoint 8**: TASK-011 — Docs updated diff --git a/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorDistributionOptions.cs b/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorDistributionOptions.cs index 54a3db1c8..0d065cdb7 100644 --- a/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorDistributionOptions.cs +++ b/src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorDistributionOptions.cs @@ -8,6 +8,30 @@ public sealed class MirrorDistributionOptions { public const string SectionName = "Excititor:Mirror"; + /// + /// All source categories recognized by the mirror export system. This list must stay + /// in sync with SourceCategory in Concelier.Core. Used by the + /// sourceCategory filter shorthand in . + /// Operators can specify one or more comma-separated values from this set. + /// + public static readonly IReadOnlyList SupportedCategories = new[] + { + "Primary", + "Vendor", + "Distribution", + "Ecosystem", + "Cert", + "Csaf", + "Threat", + "Exploit", + "Container", + "Hardware", + "Ics", + "PackageManager", + "Mirror", + "Other", + }; + /// /// Global enable flag for mirror distribution surfaces and bundle generation. /// @@ -94,6 +118,12 @@ public sealed class MirrorExportOptions /// into normalized multi-value lists. Source definitions are required for resolving /// sourceCategory and sourceTag shorthands; pass null when /// category/tag expansion is not needed. + /// + /// Both sourceCategory and sourceTag accept comma-separated values, + /// e.g. "Exploit,Container,Ics,PackageManager". All matching source IDs are + /// merged into the resolved sourceVendor list. + /// See for valid category names. + /// /// /// /// Optional catalog of source definitions used to resolve sourceCategory and @@ -116,9 +146,13 @@ public sealed class MirrorExportOptions if (key.Equals("sourceCategory", StringComparison.OrdinalIgnoreCase) && sourceDefinitions is not null) { - // Resolve category to source IDs + // Resolve one or more comma-separated categories to source IDs. + // Supports both single ("Exploit") and multi-value ("Exploit,Container,Ics,PackageManager"). + var categories = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var categorySet = new HashSet(categories, StringComparer.OrdinalIgnoreCase); + var matchingIds = sourceDefinitions - .Where(s => s.Category.Equals(value, StringComparison.OrdinalIgnoreCase)) + .Where(s => categorySet.Contains(s.Category)) .Select(s => s.Id) .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -130,9 +164,12 @@ public sealed class MirrorExportOptions } else if (key.Equals("sourceTag", StringComparison.OrdinalIgnoreCase) && sourceDefinitions is not null) { - // Resolve tag to source IDs + // Resolve one or more comma-separated tags to source IDs. + var tags = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var tagSet = new HashSet(tags, StringComparer.OrdinalIgnoreCase); + var matchingIds = sourceDefinitions - .Where(s => s.Tags.Contains(value, StringComparer.OrdinalIgnoreCase)) + .Where(s => s.Tags.Any(t => tagSet.Contains(t))) .Select(s => s.Id) .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) .ToList(); diff --git a/src/Web/StellaOps.Web/e2e/advisory-vex-source-management.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/advisory-vex-source-management.e2e.spec.ts new file mode 100644 index 000000000..e3dbe1f35 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/advisory-vex-source-management.e2e.spec.ts @@ -0,0 +1,348 @@ +/** + * Advisory & VEX Source Management — E2E Tests + * + * Verifies the source catalog UI, enable/disable toggles, connectivity checks, + * onboarding hub section, and security page health display. + */ + +import { test, expect } from './fixtures/auth.fixture'; +import { navigateAndWait } from './helpers/nav.helper'; + +// --------------------------------------------------------------------------- +// Fixtures: mock API responses for deterministic E2E +// --------------------------------------------------------------------------- + +const MOCK_CATALOG = { + items: [ + { id: 'nvd', displayName: 'NVD', category: 'Primary', type: 'Upstream', description: 'NIST National Vulnerability Database', baseEndpoint: 'https://services.nvd.nist.gov', requiresAuth: true, credentialEnvVar: 'NVD_API_KEY', credentialUrl: 'https://nvd.nist.gov/developers/request-an-api-key', documentationUrl: 'https://nvd.nist.gov/developers', defaultPriority: 10, regions: [], tags: ['cve'], enabledByDefault: true }, + { id: 'osv', displayName: 'OSV', category: 'Primary', type: 'Upstream', description: 'Open Source Vulnerabilities', baseEndpoint: 'https://api.osv.dev', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: 'https://osv.dev', defaultPriority: 20, regions: [], tags: ['ecosystem'], enabledByDefault: true }, + { id: 'ghsa', displayName: 'GitHub Security Advisories', category: 'Primary', type: 'Upstream', description: 'GitHub Advisory Database', baseEndpoint: 'https://api.github.com', requiresAuth: true, credentialEnvVar: 'GITHUB_TOKEN', credentialUrl: null, documentationUrl: null, defaultPriority: 30, regions: [], tags: ['ecosystem'], enabledByDefault: true }, + { id: 'redhat', displayName: 'Red Hat Security', category: 'Vendor', type: 'Upstream', description: 'Red Hat Product Security advisories', baseEndpoint: 'https://access.redhat.com', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 50, regions: [], tags: ['vendor'], enabledByDefault: true }, + { id: 'debian', displayName: 'Debian Security Tracker', category: 'Distribution', type: 'Upstream', description: 'Debian Security Bug Tracker', baseEndpoint: 'https://security-tracker.debian.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 60, regions: [], tags: ['distro'], enabledByDefault: true }, + { id: 'ubuntu', displayName: 'Ubuntu Security', category: 'Distribution', type: 'Upstream', description: 'Ubuntu CVE Tracker', baseEndpoint: 'https://ubuntu.com/security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 61, regions: [], tags: ['distro'], enabledByDefault: false }, + { id: 'npm-audit', displayName: 'npm Audit', category: 'Ecosystem', type: 'Upstream', description: 'npm advisory feed', baseEndpoint: 'https://registry.npmjs.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 70, regions: [], tags: ['ecosystem'], enabledByDefault: true }, + { id: 'certfr', displayName: 'CERT-FR', category: 'Cert', type: 'Upstream', description: 'French national CERT advisories', baseEndpoint: 'https://www.cert.ssi.gouv.fr', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 80, regions: ['eu'], tags: ['cert'], enabledByDefault: false }, + { id: 'csaf-aggregator', displayName: 'CSAF Trusted Provider', category: 'Csaf', type: 'Upstream', description: 'OASIS CSAF trusted provider', baseEndpoint: 'https://csaf.data.security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 90, regions: [], tags: ['csaf'], enabledByDefault: true }, + { id: 'kev', displayName: 'CISA KEV', category: 'Threat', type: 'Upstream', description: 'Known Exploited Vulnerabilities catalog', baseEndpoint: 'https://www.cisa.gov', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 15, regions: ['us'], tags: ['exploit'], enabledByDefault: true }, + { id: 'stella-mirror', displayName: 'StellaOps Mirror', category: 'Mirror', type: 'Mirror', description: 'Local offline mirror', baseEndpoint: 'http://mirror.local', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 100, regions: [], tags: ['mirror'], enabledByDefault: false }, + ], + totalCount: 11, +}; + +const MOCK_STATUS = { + sources: MOCK_CATALOG.items.map((s) => ({ + sourceId: s.id, + enabled: s.enabledByDefault, + lastCheck: s.enabledByDefault + ? { sourceId: s.id, status: 'Healthy', checkedAt: new Date().toISOString(), latency: '00:00:00.1500000', isHealthy: true, possibleReasons: [], remediationSteps: [] } + : null, + })), +}; + +const MOCK_ADVISORY_SOURCES = { + items: [ + { sourceId: 'aaa-1111', sourceKey: 'nvd', sourceName: 'NVD', sourceFamily: 'primary', sourceUrl: null, priority: 10, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 600, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 42, errorCount: 0, totalAdvisories: 1000, signedAdvisories: 0, unsignedAdvisories: 1000, signatureFailureCount: 0 }, + { sourceId: 'bbb-2222', sourceKey: 'osv', sourceName: 'OSV', sourceFamily: 'primary', sourceUrl: null, priority: 20, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 1200, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 30, errorCount: 0, totalAdvisories: 500, signedAdvisories: 0, unsignedAdvisories: 500, signatureFailureCount: 0 }, + { sourceId: 'ccc-3333', sourceKey: 'ghsa', sourceName: 'GitHub Advisory DB', sourceFamily: 'primary', sourceUrl: null, priority: 30, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 7200, freshnessSlaSeconds: 3600, freshnessStatus: 'warning', signatureStatus: 'unsigned', lastError: null, syncCount: 10, errorCount: 1, totalAdvisories: 200, signedAdvisories: 0, unsignedAdvisories: 200, signatureFailureCount: 0 }, + ], + totalCount: 3, + dataAsOf: new Date().toISOString(), +}; + +function setupSourceApiMocks(page: import('@playwright/test').Page) { + // Source management API mocks + page.route('**/api/v1/sources/catalog', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CATALOG) }); + }); + + page.route('**/api/v1/sources/status', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_STATUS) }); + }); + + page.route('**/api/v1/sources/*/enable', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + }); + + page.route('**/api/v1/sources/*/disable', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + }); + + page.route('**/api/v1/sources/check', (route) => { + if (route.request().method() === 'POST') { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ totalChecked: 11, healthyCount: 8, failedCount: 3 }), + }); + } else { + route.continue(); + } + }); + + page.route('**/api/v1/sources/*/check', (route) => { + if (route.request().method() === 'POST') { + const url = route.request().url(); + const sourceId = url.split('/sources/')[1]?.split('/check')[0] ?? 'unknown'; + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sourceId, + status: 'Healthy', + checkedAt: new Date().toISOString(), + latency: '00:00:00.0850000', + isHealthy: true, + possibleReasons: [], + remediationSteps: [], + }), + }); + } else { + route.continue(); + } + }); + + page.route('**/api/v1/sources/*/check-result', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sourceId: 'nvd', + status: 'Healthy', + checkedAt: new Date().toISOString(), + latency: '00:00:00.1000000', + isHealthy: true, + possibleReasons: [], + remediationSteps: [], + }), + }); + }); + + page.route('**/api/v1/sources/batch-enable', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ results: [{ sourceId: 'ubuntu', success: true }, { sourceId: 'certfr', success: true }] }), + }); + }); + + page.route('**/api/v1/sources/batch-disable', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ results: [{ sourceId: 'nvd', success: true }] }), + }); + }); + + // Advisory sources API mock (for security page) + page.route('**/api/v1/advisory-sources?*', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ADVISORY_SOURCES) }); + }); + + page.route('**/api/v1/advisory-sources/summary', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ totalSources: 3, healthySources: 2, warningSources: 1, staleSources: 0, unavailableSources: 0, disabledSources: 0, conflictingSources: 0, dataAsOf: new Date().toISOString() }), + }); + }); + + // Integration service provider catalog + page.route('**/api/v1/integrations/providers', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { provider: 'Harbor', name: 'Harbor', type: 'Registry' }, + { provider: 'GitHubApp', name: 'GitHub App', type: 'Scm' }, + ]), + }); + }); + + // Catch-all for other security/integration endpoints + page.route('**/api/v2/security/**', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], table: [] }) }); + }); + + page.route('**/api/v2/integrations/**', (route) => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) }); + }); +} + +function setupErrorCollector(page: import('@playwright/test').Page) { + const errors: string[] = []; + page.on('console', (msg) => { + const text = msg.text(); + if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) { + errors.push(text); + } + }); + return errors; +} + +// --------------------------------------------------------------------------- +// Test: Source catalog renders with categories and source rows +// --------------------------------------------------------------------------- +test.describe('Advisory Source Catalog', () => { + test('renders catalog with category sections and source rows', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await setupSourceApiMocks(page); + + await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 }); + await page.waitForTimeout(2000); + + // Verify page heading + const body = await page.locator('body').innerText(); + expect(body.toLowerCase()).toContain('advisory'); + + // Verify category sections exist + for (const category of ['Primary', 'Vendor', 'Distribution', 'Ecosystem', 'Cert', 'Csaf', 'Threat', 'Mirror']) { + const categoryText = await page.locator('body').innerText(); + expect(categoryText).toContain(category); + } + + // Verify source names render + expect(body).toContain('NVD'); + expect(body).toContain('OSV'); + expect(body).toContain('GitHub Security Advisories'); + expect(body).toContain('Red Hat Security'); + + // Verify stats bar shows counts + expect(body).toMatch(/\d+\s*(enabled|healthy|failed)/i); + + expect(ngErrors).toHaveLength(0); + }); + + test('enable/disable toggle updates source state', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await setupSourceApiMocks(page); + + await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 }); + await page.waitForTimeout(2000); + + // Find a toggle/button for a disabled source (ubuntu) + const ubuntuRow = page.locator('text=Ubuntu Security').locator('..'); + await expect(ubuntuRow).toBeVisible({ timeout: 5000 }); + + // Look for a toggle or enable button in the row + const toggle = ubuntuRow.locator('button, input[type="checkbox"], .toggle').first(); + if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) { + await toggle.click(); + await page.waitForTimeout(1000); + } + + expect(ngErrors).toHaveLength(0); + }); + + test('check button shows connectivity result', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await setupSourceApiMocks(page); + + await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 }); + await page.waitForTimeout(2000); + + // Find a "Check" button on a source row + const checkBtn = page.locator('button').filter({ hasText: /check/i }).first(); + if (await checkBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await checkBtn.click(); + await page.waitForTimeout(2000); + + // Verify status badge appears after check + const body = await page.locator('body').innerText(); + expect(body.toLowerCase()).toMatch(/healthy|degraded|failed|unchecked/); + } + + expect(ngErrors).toHaveLength(0); + }); + + test('Check All button triggers bulk connectivity check', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await setupSourceApiMocks(page); + + await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 }); + await page.waitForTimeout(2000); + + const checkAllBtn = page.locator('button').filter({ hasText: /check all/i }).first(); + if (await checkAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await checkAllBtn.click(); + await page.waitForTimeout(2000); + } + + expect(ngErrors).toHaveLength(0); + }); + + test('Enable All for a category enables all sources in that category', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await setupSourceApiMocks(page); + + await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 }); + await page.waitForTimeout(2000); + + // Find an "Enable All" button within a category section + const enableAllBtn = page.locator('button').filter({ hasText: /enable all/i }).first(); + if (await enableAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await enableAllBtn.click(); + await page.waitForTimeout(1000); + } + + expect(ngErrors).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Test: Onboarding hub shows Advisory & VEX Sources section +// --------------------------------------------------------------------------- +test.describe('Onboarding Hub - Advisory Sources', () => { + test('shows Advisory & VEX Sources section with Configure button', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await setupSourceApiMocks(page); + + await navigateAndWait(page, '/setup/integrations/onboarding', { timeout: 30_000 }); + await page.waitForTimeout(2000); + + const body = await page.locator('body').innerText(); + expect(body).toContain('Advisory & VEX Sources'); + + // Verify the "Configure Sources" button exists + const configureBtn = page.locator('button').filter({ hasText: /configure sources/i }); + await expect(configureBtn).toBeVisible({ timeout: 5000 }); + + // Click it and verify navigation to catalog + await configureBtn.click(); + await page.waitForTimeout(2000); + + expect(page.url()).toContain('advisory-vex-sources'); + + expect(ngErrors).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Test: Security page shows real source names with status +// --------------------------------------------------------------------------- +test.describe('Security Page - Advisory Health', () => { + test('Advisories & VEX Health shows real source names', async ({ authenticatedPage: page }) => { + const ngErrors = setupErrorCollector(page); + await setupSourceApiMocks(page); + + await navigateAndWait(page, '/security', { timeout: 30_000 }); + await page.waitForTimeout(3000); + + const body = await page.locator('body').innerText(); + + // Verify the section exists + expect(body).toContain('Advisories & VEX Health'); + + // Verify real source names from mock (not "offline - unknown") + const healthSection = page.locator('text=Advisories & VEX Health').locator('..').locator('..'); + const healthText = await healthSection.innerText().catch(() => body); + + // Should contain real source names from the advisory-sources API + const hasRealSources = healthText.includes('NVD') || healthText.includes('OSV') || healthText.includes('GitHub'); + const hasOfflineUnknown = (healthText.match(/offline.*unknown/gi) || []).length; + + // We expect real sources OR at least no "offline - unknown" for all entries + expect(hasRealSources || hasOfflineUnknown === 0).toBeTruthy(); + + expect(ngErrors).toHaveLength(0); + }); +}); 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 a33f98847..8c4d69255 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 @@ -2,6 +2,7 @@ * Integration Hub Routes * Updated: SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck * Updated: Sprint 023 - Registry admin mounted under integrations (FE-URG-002) + * Updated: Sprint 007 - Mirror dashboard route (TASK-007b) * * Canonical Integrations taxonomy: * '' - Hub overview with health summary and category navigation @@ -82,9 +83,23 @@ export const integrationHubRoutes: Routes = [ { path: 'advisory-vex-sources', title: 'Advisory & VEX Sources', - data: { breadcrumb: 'Advisory & VEX Sources', type: 'FeedMirror' }, + data: { breadcrumb: 'Advisory & VEX Sources' }, loadComponent: () => - import('./integration-list.component').then((m) => m.IntegrationListComponent), + import('../integrations/advisory-vex-sources/advisory-source-catalog.component').then((m) => m.AdvisorySourceCatalogComponent), + }, + { + path: 'advisory-vex-sources/mirror', + title: 'Mirror Dashboard', + data: { breadcrumb: 'Mirror Dashboard' }, + loadComponent: () => + import('../integrations/advisory-vex-sources/mirror-dashboard.component').then((m) => m.MirrorDashboardComponent), + }, + { + path: 'advisory-vex-sources/mirror/new', + title: 'Create Mirror Domain', + data: { breadcrumb: 'Create Mirror Domain' }, + loadComponent: () => + import('../integrations/advisory-vex-sources/mirror-domain-builder.component').then((m) => m.MirrorDomainBuilderComponent), }, { 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 new file mode 100644 index 000000000..4696fbfc0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts @@ -0,0 +1,1263 @@ +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { forkJoin } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { + MirrorManagementApi, + MirrorConfigResponse, + MirrorHealthSummary, +} from './mirror-management.api'; +import { + SourceManagementApi, + SourceCatalogItem, + SourceStatusItem, + SourceConnectivityResultDto, +} from './source-management.api'; + +const CATEGORY_ORDER = [ + 'Primary', + 'Vendor', + 'Distribution', + 'Ecosystem', + 'Cert', + 'Csaf', + 'Threat', + 'Exploit', + 'Container', + 'Hardware', + 'Ics', + 'PackageManager', + 'Mirror', + 'Other', +]; + +const CATEGORY_DESCRIPTIONS: Record = { + Primary: 'Primary vulnerability databases (NVD, OSV, GHSA)', + Vendor: 'Vendor-specific security advisories and bulletins', + Distribution: 'Linux distribution security advisories', + Ecosystem: 'Language ecosystem advisory aggregators', + Cert: 'National CERTs and government security response teams', + Csaf: 'CSAF/VEX document sources and providers', + Threat: 'Threat intelligence and adversary technique databases', + Exploit: 'Exploit databases and proof-of-concept repositories', + Container: 'Container image advisory sources (Docker, OCI)', + Hardware: 'Hardware and firmware PSIRT advisories (CPU, SoC)', + Ics: 'Industrial control systems and SCADA advisories', + PackageManager: 'Package manager native advisory databases (cargo-audit, pip-audit, govulncheck)', + Mirror: 'StellaOps pre-aggregated advisory mirrors', + Other: 'Other and uncategorized advisory sources', +}; + +interface CategoryGroup { + category: string; + items: SourceCatalogItem[]; +} + +@Component({ + selector: 'app-advisory-source-catalog', + standalone: true, + imports: [RouterModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+

Advisory & VEX Source Catalog

+

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

+
+ +
+ + + @if (mirrorConfig()) { +
+
+ + {{ mirrorConfig()!.mode }} + + Configure Mirror +
+
+ @if (mirrorHealth() && mirrorHealth()!.totalDomains > 0) { + + {{ mirrorHealth()!.totalDomains }} mirror domains + + + {{ mirrorHealth()!.totalAdvisoryCount }} bundled advisories + + } + +
+
+ } + + @if (loading()) { + + } @else { +
+ {{ enabledCount() }} enabled + {{ healthyCount() }} healthy + {{ failedCount() }} failed + @if (mirrorHealth() && mirrorHealth()!.totalDomains > 0) { + {{ mirrorHealth()!.totalDomains }} mirror domains + {{ mirrorHealth()!.totalAdvisoryCount }} bundled advisories + } +
+ +
+ + +
+ + @for (group of groupedByCategory(); track group.category) { +
+
+
+

+ + {{ group.category }} + ({{ group.items.length }}) +

+ @if (getCategoryDescription(group.category); as desc) { +

{{ desc }}

+ } +
+
+ + +
+
+ + @if (!isCategoryCollapsed(group.category)) { +
+ @for (source of group.items; track source.id) { +
+
+ + +
+ +
+ +
+ {{ source.displayName }} + {{ source.description }} +
+ +
+ @if (source.requiresAuth) { + 🔒 + } + + + {{ getSourceHealthStatus(source.id) }} + +
+ + +
+ + @if (expandedSourceId() === source.id) { +
+
+
+ Endpoint + {{ source.baseEndpoint }} +
+
+ Type + {{ source.type }} +
+
+ Priority + {{ source.defaultPriority }} +
+ @if (source.regions.length > 0) { +
+ Regions + {{ source.regions.join(', ') }} +
+ } + @if (source.tags.length > 0) { +
+ Tags + {{ source.tags.join(', ') }} +
+ } + @if (source.requiresAuth && source.credentialEnvVar) { +
+ Credential Env Var + {{ source.credentialEnvVar }} +
+ } + @if (source.documentationUrl) { +
+ Documentation + {{ source.documentationUrl }} +
+ } +
+ + @if (getSourceLastCheck(source.id); as check) { +
+

Health Check Result

+
+
+ Status + + {{ check.status }} + +
+
+ Checked At + {{ check.checkedAt }} +
+ @if (check.latency) { +
+ Latency + {{ check.latency }} +
+ } + @if (check.httpStatusCode) { +
+ HTTP Status + {{ check.httpStatusCode }} +
+ } + @if (check.errorMessage) { +
+ Error + {{ check.errorMessage }} +
+ } +
+ + @if (check.possibleReasons.length > 0) { +
+
Possible Reasons
+
    + @for (reason of check.possibleReasons; track reason) { +
  • {{ reason }}
  • + } +
+
+ } + + @if (check.remediationSteps.length > 0) { +
+
Remediation Steps
+
    + @for (step of check.remediationSteps; track step.order) { +
  1. + {{ step.description }} + @if (step.command) { + {{ step.command }} + } + @if (step.documentationUrl) { + Docs + } +
  2. + } +
+
+ } +
+ } +
+ } +
+ } +
+ } +
+ } @empty { + + } + + @if (selectedIds().size > 0) { +
+ {{ selectedIds().size }} selected + + +
+ } + } +
+ `, + styles: [` + .source-catalog { + max-width: 1100px; + margin: 0 auto; + padding: 2rem; + display: grid; + gap: 1rem; + } + + .catalog-header { + display: flex; + justify-content: space-between; + align-items: start; + gap: 1rem; + } + + .catalog-header h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; + font-weight: var(--font-weight-semibold); + } + + .catalog-header p { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .banner { + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .stats-bar { + display: flex; + gap: 1.5rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + font-size: 0.85rem; + color: var(--color-text-secondary); + } + + .stat strong { + color: var(--color-text-heading); + } + + .stat--healthy strong { + color: var(--color-status-success-text, #22c55e); + } + + .stat--failed strong { + color: var(--color-status-error-text, #ef4444); + } + + .filter-bar { + display: flex; + gap: 0.75rem; + } + + .filter-search { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-heading); + font-size: 0.85rem; + } + + .filter-search::placeholder { + color: var(--color-text-secondary); + } + + .filter-category { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-heading); + font-size: 0.85rem; + min-width: 160px; + } + + .category-section { + padding: 1rem 1.25rem; + background: var(--color-surface-secondary); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-primary); + } + + .category-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + } + + .category-header-info { + display: grid; + gap: 0.15rem; + min-width: 0; + } + + .category-desc { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-secondary); + padding-left: 1.1rem; + } + + .category-title { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + user-select: none; + } + + .category-chevron { + display: inline-block; + font-size: 0.6rem; + transition: transform 0.15s ease; + color: var(--color-text-secondary); + } + + .category-chevron.expanded { + transform: rotate(90deg); + } + + .category-count { + font-size: 0.8rem; + color: var(--color-text-secondary); + font-weight: normal; + } + + .category-actions { + display: flex; + gap: 0.5rem; + } + + .source-list { + margin-top: 0.75rem; + display: grid; + gap: 0.25rem; + } + + .source-row { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + overflow: hidden; + } + + .source-row.expanded { + border-color: var(--color-brand-primary); + } + + .source-main { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + cursor: pointer; + } + + .source-main:hover { + background: var(--color-surface-secondary); + } + + .source-checkbox input { + cursor: pointer; + } + + .source-toggle { + flex-shrink: 0; + } + + .toggle-btn { + border: none; + background: none; + padding: 0; + cursor: pointer; + } + + .toggle-track { + display: block; + width: 32px; + height: 18px; + border-radius: 9px; + background: var(--color-border-primary); + position: relative; + transition: background 0.15s ease; + } + + .toggle-on .toggle-track { + background: var(--color-brand-primary); + } + + .toggle-thumb { + display: block; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + position: absolute; + top: 2px; + left: 2px; + transition: left 0.15s ease; + } + + .toggle-on .toggle-thumb { + left: 16px; + } + + .source-info { + flex: 1; + min-width: 0; + display: grid; + gap: 0.15rem; + } + + .source-name { + font-size: 0.85rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-heading); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-desc { + font-size: 0.75rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + } + + .auth-badge { + font-size: 0.75rem; + color: var(--color-text-secondary); + } + + .status-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.7rem; + padding: 0.15rem 0.45rem; + border-radius: var(--radius-full, 9999px); + border: 1px solid var(--color-border-primary); + color: var(--color-text-secondary); + text-transform: lowercase; + } + + .status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-text-secondary); + } + + .status-badge--healthy .status-dot { background: #22c55e; } + .status-badge--healthy { color: #22c55e; border-color: #22c55e40; } + .status-badge--degraded .status-dot { background: #eab308; } + .status-badge--degraded { color: #eab308; border-color: #eab30840; } + .status-badge--failed .status-dot { background: #ef4444; } + .status-badge--failed { color: #ef4444; border-color: #ef444440; } + .status-badge--unchecked .status-dot { background: #9ca3af; } + .status-badge--unchecked { color: #9ca3af; } + + .btn { + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + border: none; + font-size: 0.85rem; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-primary { + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .btn-secondary { + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-heading); + } + + .btn-sm { + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-secondary); + } + + .btn-check { + flex-shrink: 0; + } + + .source-detail { + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--color-border-primary); + display: grid; + gap: 0.75rem; + } + + .detail-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.5rem; + } + + .detail-field { + display: grid; + gap: 0.1rem; + } + + .detail-field--full { + grid-column: 1 / -1; + } + + .detail-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--color-text-secondary); + } + + .detail-value { + font-size: 0.82rem; + color: var(--color-text-heading); + } + + .detail-value.code { + font-family: monospace; + font-size: 0.78rem; + } + + .detail-link { + font-size: 0.82rem; + color: var(--color-brand-primary); + text-decoration: none; + } + + .detail-link:hover { + text-decoration: underline; + } + + .error-text { + color: var(--color-status-error-text, #ef4444); + } + + .health-detail { + border-top: 1px solid var(--color-border-primary); + padding-top: 0.75rem; + display: grid; + gap: 0.5rem; + } + + .health-detail h4 { + margin: 0; + font-size: 0.85rem; + font-weight: var(--font-weight-semibold); + } + + .health-status--healthy { color: #22c55e; } + .health-status--failed { color: #ef4444; } + + .reasons, .remediation { + display: grid; + gap: 0.25rem; + } + + .reasons h5, .remediation h5 { + margin: 0; + font-size: 0.78rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + } + + .reasons ul, .remediation ol { + margin: 0; + padding-left: 1.25rem; + font-size: 0.78rem; + color: var(--color-text-secondary); + } + + .remediation-cmd { + display: block; + margin-top: 0.2rem; + padding: 0.25rem 0.5rem; + background: var(--color-surface-secondary); + border-radius: var(--radius-sm); + font-size: 0.72rem; + font-family: monospace; + } + + .batch-actions { + position: sticky; + bottom: 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-brand-primary); + border-radius: var(--radius-lg); + font-size: 0.85rem; + color: var(--color-text-heading); + } + + /* -- Mirror context header -------------------------------------------- */ + + .mirror-context { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.6rem 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + } + + .mirror-context-left { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .mirror-context-right { + display: flex; + align-items: center; + gap: 1rem; + } + + .mirror-mode-badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-full, 9999px); + font-size: 0.7rem; + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.04em; + border: 1px solid var(--color-border-primary); + color: var(--color-text-secondary); + } + + .mirror-mode-badge--direct { + color: #22c55e; + border-color: #22c55e40; + background: #22c55e10; + } + + .mirror-mode-badge--mirror { + color: #3b82f6; + border-color: #3b82f640; + background: #3b82f610; + } + + .mirror-mode-badge--hybrid { + color: #a855f7; + border-color: #a855f740; + background: #a855f710; + } + + .mirror-link { + font-size: 0.8rem; + color: var(--color-brand-primary); + text-decoration: none; + font-weight: var(--font-weight-medium); + } + + .mirror-link:hover { + text-decoration: underline; + } + + .mirror-stat { + font-size: 0.8rem; + color: var(--color-text-secondary); + } + + .mirror-stat strong { + color: var(--color-text-heading); + } + + .btn-mirror { + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .stat--mirror strong { + color: #3b82f6; + } + + @media (max-width: 720px) { + .catalog-header { + flex-direction: column; + } + + .filter-bar { + flex-direction: column; + } + + .detail-grid { + grid-template-columns: 1fr; + } + + .mirror-context { + flex-direction: column; + align-items: stretch; + } + + .mirror-context-left, + .mirror-context-right { + justify-content: space-between; + } + } + `], +}) +export class AdvisorySourceCatalogComponent implements OnInit { + private readonly api = inject(SourceManagementApi); + private readonly mirrorApi = inject(MirrorManagementApi); + private readonly router = inject(Router); + + readonly catalog = signal([]); + readonly statuses = signal>(new Map()); + readonly loading = signal(true); + readonly checking = signal(false); + readonly checkProgress = signal({ done: 0, total: 0 }); + readonly selectedIds = signal>(new Set()); + readonly searchTerm = signal(''); + readonly categoryFilter = signal(null); + readonly expandedSourceId = signal(null); + readonly collapsedCategories = signal>(new Set()); + + // Mirror context state + readonly mirrorConfig = signal(null); + readonly mirrorHealth = signal(null); + + readonly categoryOptions = CATEGORY_ORDER; + + readonly filteredCatalog = computed(() => { + let items = this.catalog(); + const term = this.searchTerm().toLowerCase(); + const cat = this.categoryFilter(); + + if (term) { + items = items.filter( + (item) => + item.displayName.toLowerCase().includes(term) || + item.description.toLowerCase().includes(term) + ); + } + + if (cat) { + items = items.filter((item) => item.category === cat); + } + + return items; + }); + + readonly groupedByCategory = computed((): CategoryGroup[] => { + const items = this.filteredCatalog(); + const map = new Map(); + + for (const item of items) { + const cat = CATEGORY_ORDER.includes(item.category) ? item.category : 'Other'; + const list = map.get(cat); + if (list) { + list.push(item); + } else { + map.set(cat, [item]); + } + } + + const groups: CategoryGroup[] = []; + for (const cat of CATEGORY_ORDER) { + const catItems = map.get(cat); + if (catItems && catItems.length > 0) { + groups.push({ category: cat, items: catItems }); + } + } + + return groups; + }); + + readonly enabledCount = computed(() => { + const statusMap = this.statuses(); + let count = 0; + statusMap.forEach((status) => { + if (status.enabled) count++; + }); + return count; + }); + + readonly healthyCount = computed(() => { + const statusMap = this.statuses(); + let count = 0; + statusMap.forEach((status) => { + if (status.lastCheck?.isHealthy) count++; + }); + return count; + }); + + readonly failedCount = computed(() => { + const statusMap = this.statuses(); + let count = 0; + statusMap.forEach((status) => { + if (status.lastCheck && !status.lastCheck.isHealthy) count++; + }); + return count; + }); + + ngOnInit(): void { + this.loadData(); + } + + onSearchInput(event: Event): void { + const input = event.target as HTMLInputElement; + this.searchTerm.set(input.value); + } + + onCategoryFilterChange(event: Event): void { + const select = event.target as HTMLSelectElement; + this.categoryFilter.set(select.value || null); + } + + toggleCategory(category: string): void { + this.collapsedCategories.update((current) => { + const next = new Set(current); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + } + + isCategoryCollapsed(category: string): boolean { + return this.collapsedCategories().has(category); + } + + getCategoryDescription(category: string): string | null { + return CATEGORY_DESCRIPTIONS[category] ?? null; + } + + onCreateMirrorDomain(): void { + this.router.navigate(['/integrations', 'advisory-vex-sources', 'mirror', 'new']); + } + + toggleExpanded(sourceId: string): void { + this.expandedSourceId.set(this.expandedSourceId() === sourceId ? null : sourceId); + } + + toggleSelected(sourceId: string): void { + this.selectedIds.update((current) => { + const next = new Set(current); + if (next.has(sourceId)) { + next.delete(sourceId); + } else { + next.add(sourceId); + } + return next; + }); + } + + isSourceEnabled(sourceId: string): boolean { + return this.statuses().get(sourceId)?.enabled ?? false; + } + + getSourceHealthStatus(sourceId: string): string { + const status = this.statuses().get(sourceId); + if (!status?.lastCheck) return 'unchecked'; + if (status.lastCheck.isHealthy) return 'healthy'; + if (status.lastCheck.status === 'degraded') return 'degraded'; + return 'failed'; + } + + getSourceLastCheck(sourceId: string): SourceConnectivityResultDto | null { + return this.statuses().get(sourceId)?.lastCheck ?? null; + } + + toggleSourceEnabled(sourceId: string): void { + const currentlyEnabled = this.isSourceEnabled(sourceId); + + // Optimistic update + this.statuses.update((current) => { + const next = new Map(current); + const existing = next.get(sourceId); + if (existing) { + next.set(sourceId, { ...existing, enabled: !currentlyEnabled }); + } else { + next.set(sourceId, { sourceId, enabled: !currentlyEnabled }); + } + return next; + }); + + const op$ = currentlyEnabled + ? this.api.disableSource(sourceId) + : this.api.enableSource(sourceId); + + op$.pipe(take(1)).subscribe({ + error: () => { + // Revert optimistic update + this.statuses.update((current) => { + const next = new Map(current); + const existing = next.get(sourceId); + if (existing) { + next.set(sourceId, { ...existing, enabled: currentlyEnabled }); + } + return next; + }); + }, + }); + } + + onCheckAll(): void { + const items = this.catalog(); + this.checking.set(true); + this.checkProgress.set({ done: 0, total: items.length }); + + this.api.checkAll().pipe(take(1)).subscribe({ + next: () => { + this.checking.set(false); + this.reloadStatus(); + }, + error: () => { + this.checking.set(false); + this.reloadStatus(); + }, + }); + } + + onCheckSource(sourceId: string): void { + this.api.checkSource(sourceId).pipe(take(1)).subscribe({ + next: (result) => { + this.statuses.update((current) => { + const next = new Map(current); + const existing = next.get(sourceId); + if (existing) { + next.set(sourceId, { ...existing, lastCheck: result }); + } else { + next.set(sourceId, { sourceId, enabled: false, lastCheck: result }); + } + return next; + }); + }, + }); + } + + enableAllInCategory(category: string): void { + const ids = this.catalog() + .filter((item) => { + const cat = CATEGORY_ORDER.includes(item.category) ? item.category : 'Other'; + return cat === category; + }) + .map((item) => item.id); + + if (ids.length === 0) return; + + // Optimistic update + this.statuses.update((current) => { + const next = new Map(current); + for (const id of ids) { + const existing = next.get(id); + if (existing) { + next.set(id, { ...existing, enabled: true }); + } else { + next.set(id, { sourceId: id, enabled: true }); + } + } + return next; + }); + + this.api.batchEnable(ids).pipe(take(1)).subscribe({ + error: () => this.reloadStatus(), + }); + } + + disableAllInCategory(category: string): void { + const ids = this.catalog() + .filter((item) => { + const cat = CATEGORY_ORDER.includes(item.category) ? item.category : 'Other'; + return cat === category; + }) + .map((item) => item.id); + + if (ids.length === 0) return; + + // Optimistic update + this.statuses.update((current) => { + const next = new Map(current); + for (const id of ids) { + const existing = next.get(id); + if (existing) { + next.set(id, { ...existing, enabled: false }); + } else { + next.set(id, { sourceId: id, enabled: false }); + } + } + return next; + }); + + this.api.batchDisable(ids).pipe(take(1)).subscribe({ + error: () => this.reloadStatus(), + }); + } + + batchEnableSelected(): void { + const ids = [...this.selectedIds()]; + if (ids.length === 0) return; + + this.statuses.update((current) => { + const next = new Map(current); + for (const id of ids) { + const existing = next.get(id); + if (existing) { + next.set(id, { ...existing, enabled: true }); + } else { + next.set(id, { sourceId: id, enabled: true }); + } + } + return next; + }); + + this.api.batchEnable(ids).pipe(take(1)).subscribe({ + next: () => this.selectedIds.set(new Set()), + error: () => this.reloadStatus(), + }); + } + + batchDisableSelected(): void { + const ids = [...this.selectedIds()]; + if (ids.length === 0) return; + + this.statuses.update((current) => { + const next = new Map(current); + for (const id of ids) { + const existing = next.get(id); + if (existing) { + next.set(id, { ...existing, enabled: false }); + } else { + next.set(id, { sourceId: id, enabled: false }); + } + } + return next; + }); + + this.api.batchDisable(ids).pipe(take(1)).subscribe({ + next: () => this.selectedIds.set(new Set()), + error: () => this.reloadStatus(), + }); + } + + private loadData(): void { + this.loading.set(true); + + forkJoin({ + catalog: this.api.getCatalog().pipe(take(1)), + status: this.api.getStatus().pipe(take(1)), + }).subscribe({ + next: ({ catalog, status }) => { + this.catalog.set(catalog.items ?? []); + const statusMap = new Map(); + for (const item of status.sources ?? []) { + statusMap.set(item.sourceId, item); + } + this.statuses.set(statusMap); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + }, + }); + + // Load mirror context in parallel (non-blocking; errors are silently ignored) + this.loadMirrorContext(); + } + + private loadMirrorContext(): void { + this.mirrorApi.getConfig().pipe(take(1)).subscribe({ + next: (config) => this.mirrorConfig.set(config), + error: () => { /* Mirror API may not be available; silently ignore */ }, + }); + + this.mirrorApi.getHealthSummary().pipe(take(1)).subscribe({ + next: (health) => this.mirrorHealth.set(health), + error: () => { /* Mirror API may not be available; silently ignore */ }, + }); + } + + private reloadStatus(): void { + this.api.getStatus().pipe(take(1)).subscribe({ + next: (status) => { + const statusMap = new Map(); + for (const item of status.sources ?? []) { + statusMap.set(item.sourceId, item); + } + this.statuses.set(statusMap); + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-dashboard.component.ts new file mode 100644 index 000000000..caa9e6b26 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-dashboard.component.ts @@ -0,0 +1,785 @@ +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { forkJoin } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { + MirrorManagementApi, + MirrorConfigResponse, + MirrorDomainResponse, + MirrorDomainEndpointDto, + MirrorHealthSummary, +} from './mirror-management.api'; + +// ── Local helpers ──────────────────────────────────────────────────────────── + +function domainStaleness(domain: MirrorDomainResponse): 'fresh' | 'stale' | 'never' { + if (!domain.createdAt) return 'never'; + const age = Date.now() - new Date(domain.createdAt).getTime(); + const oneDay = 24 * 60 * 60 * 1000; + return age > oneDay ? 'stale' : 'fresh'; +} + +// ── Component ──────────────────────────────────────────────────────────────── + +@Component({ + selector: 'app-mirror-dashboard', + standalone: true, + imports: [RouterModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+

Mirror Dashboard

+

Manage advisory mirror domains, monitor health, and control distribution.

+
+
+ @if (config()) { + + {{ config()!.mode }} + + } + +
+
+ + @if (loading()) { + + } @else { + + @if (health()) { +
+ + {{ health()!.totalDomains }} domains + + + {{ health()!.freshCount }} fresh + + + {{ health()!.staleCount }} stale + + + {{ health()!.neverGeneratedCount }} never generated + + + {{ health()!.totalAdvisoryCount }} advisories in bundles + +
+ } + + + @if (showConsumerPanel()) { +
+

Consumer Mirror Connection

+
+
+ Mirror URL + {{ config()!.consumerMirrorUrl ?? 'Not configured' }} +
+
+ Connection Status + + {{ config()!.consumerConnected ? 'Connected' : 'Disconnected' }} + +
+ @if (config()!.lastConsumerSync) { +
+ Last Sync + {{ config()!.lastConsumerSync }} +
+ } +
+
+ } + + + @if (domains().length === 0) { +
+
+

Create your first mirror domain

+

Mirror domains bundle advisory data from enabled sources for offline distribution or downstream consumption.

+ +
+ } @else { +
+ @for (domain of domains(); track domain.id) { +
+
+
+

{{ domain.displayName }}

+ {{ domain.domainId }} +
+ + {{ getDomainStaleness(domain) }} + +
+ +
+
+ Exports + {{ domain.sourceIds.length }} +
+
+ Format + {{ domain.exportFormat }} +
+
+ Sources + {{ domain.sourceIds.length }} +
+
+ Created + {{ formatTimestamp(domain.createdAt) }} +
+
+ Status + {{ domain.status }} +
+
+ + @if (domain.sourceIds.length > 0) { +
+ @for (sid of domain.sourceIds.slice(0, 5); track sid) { + {{ sid }} + } + @if (domain.sourceIds.length > 5) { + +{{ domain.sourceIds.length - 5 }} more + } +
+ } + + + @if (expandedEndpoints() === domain.id) { +
+

Endpoints

+ @if (domainEndpoints().length === 0) { +

Loading endpoints...

+ } @else { + @for (ep of domainEndpoints(); track ep.path) { +
+ {{ ep.method }} + {{ ep.path }} + {{ ep.description }} +
+ } + } +
+ } + +
+ + + + +
+
+ } +
+ } + } +
+ `, + styles: [` + .mirror-dashboard { + max-width: 1100px; + margin: 0 auto; + padding: 2rem; + display: grid; + gap: 1.25rem; + } + + /* -- Header ----------------------------------------------------------- */ + + .dashboard-header { + display: flex; + justify-content: space-between; + align-items: start; + gap: 1rem; + } + + .dashboard-header h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; + font-weight: var(--font-weight-semibold); + } + + .dashboard-header p { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .header-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + } + + .mode-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.65rem; + border-radius: var(--radius-full, 9999px); + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.04em; + border: 1px solid var(--color-border-primary); + color: var(--color-text-secondary); + } + + .mode-badge--direct { + color: #22c55e; + border-color: #22c55e40; + background: #22c55e10; + } + + .mode-badge--mirror { + color: #3b82f6; + border-color: #3b82f640; + background: #3b82f610; + } + + .mode-badge--hybrid { + color: #a855f7; + border-color: #a855f740; + background: #a855f710; + } + + /* -- Banner / Loading ------------------------------------------------- */ + + .banner { + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + /* -- Health Bar -------------------------------------------------------- */ + + .health-bar { + display: flex; + gap: 1.5rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + font-size: 0.85rem; + color: var(--color-text-secondary); + flex-wrap: wrap; + } + + .health-stat strong { + color: var(--color-text-heading); + } + + .health-stat--fresh strong { + color: #22c55e; + } + + .health-stat--stale strong { + color: #eab308; + } + + .health-stat--never strong { + color: #9ca3af; + } + + /* -- Consumer Panel --------------------------------------------------- */ + + .consumer-panel { + padding: 1rem 1.25rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + } + + .consumer-panel h3 { + margin: 0 0 0.75rem; + font-size: 0.95rem; + font-weight: var(--font-weight-semibold); + } + + .consumer-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; + } + + .consumer-field { + display: grid; + gap: 0.15rem; + } + + .consumer-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--color-text-secondary); + } + + .consumer-value { + font-size: 0.85rem; + color: var(--color-text-heading); + } + + .consumer-value.code { + font-family: monospace; + font-size: 0.8rem; + } + + .status--connected { + color: #22c55e; + } + + .status--disconnected { + color: #ef4444; + } + + /* -- Empty State ------------------------------------------------------ */ + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 2rem; + text-align: center; + border: 2px dashed var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + } + + .empty-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; + opacity: 0.5; + } + + .empty-state h2 { + margin: 0 0 0.5rem; + font-size: 1.15rem; + font-weight: var(--font-weight-semibold); + } + + .empty-state p { + margin: 0 0 1.25rem; + color: var(--color-text-secondary); + font-size: 0.875rem; + max-width: 480px; + } + + /* -- Domain Grid ------------------------------------------------------ */ + + .domain-grid { + display: grid; + gap: 1rem; + } + + .domain-card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + overflow: hidden; + } + + .card-header { + display: flex; + justify-content: space-between; + align-items: start; + padding: 1rem 1.25rem 0; + gap: 1rem; + } + + .card-title-group { + display: grid; + gap: 0.15rem; + } + + .card-title { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + } + + .card-id { + font-size: 0.72rem; + color: var(--color-text-secondary); + font-family: monospace; + } + + .staleness-badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: var(--radius-full, 9999px); + font-size: 0.7rem; + font-weight: var(--font-weight-medium); + text-transform: lowercase; + border: 1px solid var(--color-border-primary); + flex-shrink: 0; + } + + .staleness--fresh { + color: #22c55e; + border-color: #22c55e40; + } + + .staleness--stale { + color: #eab308; + border-color: #eab30840; + } + + .staleness--never { + color: #9ca3af; + border-color: #9ca3af40; + } + + .card-stats { + display: flex; + gap: 1.25rem; + padding: 0.75rem 1.25rem; + flex-wrap: wrap; + } + + .card-stat { + display: grid; + gap: 0.1rem; + } + + .card-stat-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--color-text-secondary); + } + + .card-stat-value { + font-size: 0.82rem; + color: var(--color-text-heading); + } + + .card-sources { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + padding: 0 1.25rem 0.5rem; + } + + .source-pill { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + border-radius: var(--radius-full, 9999px); + font-size: 0.68rem; + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-secondary); + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .source-pill--more { + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + } + + /* -- Endpoints Panel -------------------------------------------------- */ + + .card-endpoints { + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--color-border-primary); + display: grid; + gap: 0.5rem; + } + + .card-endpoints h4 { + margin: 0; + font-size: 0.85rem; + font-weight: var(--font-weight-semibold); + } + + .endpoints-empty { + margin: 0; + font-size: 0.8rem; + color: var(--color-text-secondary); + } + + .endpoint-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.78rem; + } + + .endpoint-method { + display: inline-flex; + padding: 0.1rem 0.35rem; + border-radius: var(--radius-sm); + font-size: 0.65rem; + font-weight: var(--font-weight-medium); + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-secondary); + text-transform: uppercase; + flex-shrink: 0; + } + + .endpoint-path { + font-family: monospace; + font-size: 0.75rem; + color: var(--color-text-heading); + flex-shrink: 0; + } + + .endpoint-desc { + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* -- Card Actions ----------------------------------------------------- */ + + .card-actions { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + } + + /* -- Buttons ---------------------------------------------------------- */ + + .btn { + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + border: none; + font-size: 0.85rem; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-primary { + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .btn-sm { + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-secondary); + } + + .btn-sm.btn-primary { + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .btn-danger { + background: #ef444420; + color: #ef4444; + border: 1px solid #ef444440; + } + + /* -- Responsive ------------------------------------------------------- */ + + @media (max-width: 720px) { + .dashboard-header { + flex-direction: column; + } + + .header-actions { + width: 100%; + justify-content: space-between; + } + + .card-stats { + gap: 0.75rem; + } + + .consumer-grid { + grid-template-columns: 1fr; + } + } + `], +}) +export class MirrorDashboardComponent implements OnInit { + private readonly api = inject(MirrorManagementApi); + private readonly router = inject(Router); + + readonly loading = signal(true); + readonly config = signal(null); + readonly domains = signal([]); + readonly health = signal(null); + readonly regeneratingId = signal(null); + readonly deletingId = signal(null); + readonly expandedEndpoints = signal(null); + readonly domainEndpoints = signal([]); + + readonly showConsumerPanel = computed(() => { + const cfg = this.config(); + return cfg != null && (cfg.mode === 'Hybrid' || cfg.mode === 'Mirror'); + }); + + ngOnInit(): void { + this.loadData(); + } + + onCreateDomain(): void { + this.router.navigate(['/integrations', 'advisory-vex-sources', 'mirror', 'create']); + } + + onRegenerate(domainId: string): void { + this.regeneratingId.set(domainId); + + this.api.generateDomain(domainId).pipe(take(1)).subscribe({ + next: () => { + this.regeneratingId.set(null); + this.reloadDomains(); + }, + error: () => { + this.regeneratingId.set(null); + }, + }); + } + + onEditDomain(domainId: string): void { + this.router.navigate(['/integrations', 'advisory-vex-sources', 'mirror', domainId, 'edit']); + } + + onDeleteDomain(domainId: string, displayName: string): void { + if (!confirm('Delete mirror domain "' + displayName + '"? This action cannot be undone.')) { + return; + } + + this.deletingId.set(domainId); + + this.api.deleteDomain(domainId).pipe(take(1)).subscribe({ + next: () => { + this.deletingId.set(null); + this.domains.update((current) => current.filter((d) => d.id !== domainId)); + this.reloadHealth(); + }, + error: () => { + this.deletingId.set(null); + }, + }); + } + + onViewEndpoints(domainId: string): void { + if (this.expandedEndpoints() === domainId) { + this.expandedEndpoints.set(null); + this.domainEndpoints.set([]); + return; + } + + this.expandedEndpoints.set(domainId); + this.domainEndpoints.set([]); + + this.api.getDomainEndpoints(domainId).pipe(take(1)).subscribe({ + next: (response) => { + this.domainEndpoints.set(response.endpoints ?? []); + }, + error: () => { + this.domainEndpoints.set([]); + }, + }); + } + + getDomainStaleness(domain: MirrorDomainResponse): string { + return domainStaleness(domain); + } + + formatTimestamp(ts: string | null | undefined): string { + if (!ts) return 'Never'; + try { + const date = new Date(ts); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return ts; + } + } + + private loadData(): void { + this.loading.set(true); + + forkJoin({ + config: this.api.getConfig().pipe(take(1)), + domains: this.api.listDomains().pipe(take(1)), + health: this.api.getHealthSummary().pipe(take(1)), + }).subscribe({ + next: ({ config, domains, health }) => { + this.config.set(config); + this.domains.set(domains.domains ?? []); + this.health.set(health); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + }, + }); + } + + private reloadDomains(): void { + this.api.listDomains().pipe(take(1)).subscribe({ + next: (response) => { + this.domains.set(response.domains ?? []); + }, + }); + } + + private reloadHealth(): void { + this.api.getHealthSummary().pipe(take(1)).subscribe({ + next: (summary) => { + this.health.set(summary); + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts new file mode 100644 index 000000000..8e0773a0c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts @@ -0,0 +1,1528 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { take } from 'rxjs/operators'; + +import { + SourceManagementApi, + SourceCatalogItem, +} from './source-management.api'; + +import { + MirrorManagementApi, + CreateMirrorDomainRequest, + MirrorDomainResponse, + MirrorDomainGenerateResponse, +} from './mirror-management.api'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const CATEGORY_ORDER = [ + 'Primary', + 'Vendor', + 'Distribution', + 'Ecosystem', + 'Cert', + 'Csaf', + 'Threat', + 'Exploit', + 'Container', + 'Hardware', + 'Mirror', + 'Other', +]; + +const EXPORT_FORMATS = ['JSON', 'JSONL', 'OpenVEX', 'CSAF', 'CycloneDX']; + +const SIGNING_ALGORITHMS = [ + 'HMAC-SHA256', + 'HMAC-SHA384', + 'HMAC-SHA512', + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'EdDSA', +]; + +const DEFAULT_INDEX_REQUESTS_PER_HOUR = 120; +const DEFAULT_DOWNLOAD_REQUESTS_PER_HOUR = 600; + +// --------------------------------------------------------------------------- +// Internal interfaces +// --------------------------------------------------------------------------- + +interface CategoryGroup { + category: string; + items: SourceCatalogItem[]; +} + +interface CategorySummary { + category: string; + count: number; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +@Component({ + selector: 'app-mirror-domain-builder', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

Create Mirror Domain

+

Build a curated advisory mirror from upstream sources.

+
+ + + + + + + + @if (currentStep() === 0) { +
+
+
+ +
+ + + + + + @if (selectedSourceIds().size > 0) { + + } +
+ + + + + @if (loading()) { + + } @else { + + @for (group of filteredGrouped(); track group.category) { +
+
+ + +
+ + @if (!isCategoryCollapsed(group.category)) { +
+ @for (source of group.items; track source.id) { + + } +
+ } +
+ } @empty { + + } + } +
+ + + +
+ + +
+ } + + + + + @if (currentStep() === 1) { +
+
+
+

Domain Identity

+ +
+ + + Auto-generated from selection. Editable. +
+ +
+ + +
+
+ +
+

Export

+ +
+ + +
+
+ +
+

Rate Limits

+ +
+
+ + +
+
+ + +
+
+
+ +
+

Authentication

+ + +
+ +
+

Signing

+ + + + @if (signingEnabled()) { +
+
+ + +
+
+ + +
+
+ } +
+
+ + +
+ } + + + + + @if (currentStep() === 2) { +
+ @if (createdDomain()) { + +
+
+
+

Mirror Domain Created

+

{{ createdDomain()!.displayName }}

+ +
+
+ Domain URL + {{ createdDomain()!.domainUrl }} +
+
+ Status + {{ createdDomain()!.status }} +
+ @if (generateResult()) { +
+ Generate Job + {{ generateResult()!.jobId }} ({{ generateResult()!.status }}) +
+ } +
+ +
+

Next Steps

+
    +
  1. Point downstream consumers at the domain URL above.
  2. +
  3. Configure authentication if required.
  4. +
  5. Monitor generation progress in the Jobs dashboard.
  6. +
+
+ + +
+
+ } @else { + +
+
+

Domain Summary

+
+
+ Domain ID + {{ domainId() }} +
+
+ Display Name + {{ displayName() }} +
+
+ Sources + + {{ selectedSourceIds().size }} sources across {{ selectionByCategory().length }} categories + +
+
+ Export Format + {{ exportFormat() }} +
+
+ Rate Limits + + {{ indexRequestsPerHour() }} index / {{ downloadRequestsPerHour() }} download per hour + +
+
+ Authentication + {{ requireAuthentication() ? 'Required' : 'None' }} +
+
+ Signing + + @if (signingEnabled()) { + {{ signingAlgorithm() }} (key: {{ signingKeyId() || 'not set' }}) + } @else { + Disabled + } + +
+
+
+ + +
+

Resolved Filter Preview

+
{{ resolvedFilterJson() }}
+
+ + + + + @if (createError()) { + + } +
+ + + } +
+ } +
+ `, + styles: [` + /* ------------------------------------------------------------------ */ + /* Layout */ + /* ------------------------------------------------------------------ */ + + .wizard { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + display: grid; + gap: 1.25rem; + } + + .wizard-header h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; + font-weight: var(--font-weight-semibold); + } + + .wizard-header p { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + /* ------------------------------------------------------------------ */ + /* Step indicator */ + /* ------------------------------------------------------------------ */ + + .step-indicator { + display: flex; + gap: 0.5rem; + } + + .step-pill { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.9rem; + border-radius: var(--radius-full, 9999px); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s ease; + } + + .step-pill:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + .step-pill.step-active { + border-color: var(--color-brand-primary); + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .step-pill.step-done { + border-color: var(--color-status-success-text, #22c55e); + color: var(--color-status-success-text, #22c55e); + } + + .step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 0.7rem; + font-weight: var(--font-weight-semibold); + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-heading); + } + + .step-active .step-number { + background: rgba(255, 255, 255, 0.2); + } + + .step-done .step-number { + background: var(--color-status-success-text, #22c55e); + color: #fff; + } + + .step-label { + white-space: nowrap; + } + + /* ------------------------------------------------------------------ */ + /* Step panel */ + /* ------------------------------------------------------------------ */ + + .step-panel { + display: grid; + gap: 1rem; + } + + .step-body { + display: grid; + gap: 1rem; + } + + .step-body--sources { + grid-template-columns: 1fr 280px; + gap: 1.25rem; + align-items: start; + } + + .step-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-primary); + } + + /* ------------------------------------------------------------------ */ + /* Source picker (Step 0) */ + /* ------------------------------------------------------------------ */ + + .source-picker { + display: grid; + gap: 0.75rem; + } + + .shorthand-bar { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + + .filter-search { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-heading); + font-size: 0.85rem; + box-sizing: border-box; + } + + .filter-search::placeholder { + color: var(--color-text-secondary); + } + + .category-section { + padding: 0.75rem 1rem; + background: var(--color-surface-secondary); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-primary); + } + + .category-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + } + + .category-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + } + + .category-checkbox input { + cursor: pointer; + } + + .category-title { + font-size: 0.95rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading); + } + + .category-count { + font-size: 0.8rem; + color: var(--color-text-secondary); + font-weight: normal; + } + + .source-list { + margin-top: 0.6rem; + display: grid; + gap: 0.2rem; + } + + .source-row-label { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.5rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.1s ease; + } + + .source-row-label:hover { + background: var(--color-surface-primary); + } + + .source-row-label input { + cursor: pointer; + flex-shrink: 0; + } + + .source-info { + flex: 1; + min-width: 0; + display: grid; + gap: 0.1rem; + } + + .source-name { + font-size: 0.82rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-heading); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-desc { + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .auth-badge { + flex-shrink: 0; + font-size: 0.65rem; + padding: 0.1rem 0.35rem; + border-radius: var(--radius-sm); + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.03em; + } + + /* ------------------------------------------------------------------ */ + /* Selection summary sidebar */ + /* ------------------------------------------------------------------ */ + + .selection-summary { + position: sticky; + top: 2rem; + padding: 1rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + display: grid; + gap: 0.75rem; + } + + .selection-summary h3 { + margin: 0; + font-size: 0.9rem; + font-weight: var(--font-weight-semibold); + } + + .summary-total { + font-size: 0.85rem; + color: var(--color-text-heading); + } + + .summary-total strong { + color: var(--color-brand-primary); + font-size: 1.1rem; + } + + .summary-categories { + display: grid; + gap: 0.3rem; + } + + .summary-cat-row { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + } + + .summary-cat-label { + color: var(--color-text-secondary); + } + + .summary-cat-count { + font-weight: var(--font-weight-medium); + color: var(--color-text-heading); + } + + .summary-empty { + margin: 0; + font-size: 0.78rem; + color: var(--color-text-secondary); + } + + /* ------------------------------------------------------------------ */ + /* Config form (Step 1) */ + /* ------------------------------------------------------------------ */ + + .step-body--config { + max-width: 680px; + } + + .form-section { + padding: 1rem 1.25rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + display: grid; + gap: 0.75rem; + } + + .form-section h3 { + margin: 0; + font-size: 0.95rem; + font-weight: var(--font-weight-semibold); + } + + .form-group { + display: grid; + gap: 0.25rem; + } + + .form-label { + font-size: 0.78rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.02em; + } + + .form-input, + .form-select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-heading); + font-size: 0.85rem; + } + + .form-input--narrow { + max-width: 200px; + } + + .form-hint { + font-size: 0.72rem; + color: var(--color-text-secondary); + } + + .form-row { + display: flex; + gap: 1rem; + flex-wrap: wrap; + } + + .form-row .form-group { + flex: 1; + min-width: 160px; + } + + .toggle-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--color-text-heading); + cursor: pointer; + } + + .toggle-label input { + cursor: pointer; + } + + /* ------------------------------------------------------------------ */ + /* Review (Step 2) */ + /* ------------------------------------------------------------------ */ + + .step-body--review { + max-width: 720px; + } + + .review-card { + padding: 1rem 1.25rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + display: grid; + gap: 0.75rem; + } + + .review-card h3 { + margin: 0; + font-size: 0.95rem; + font-weight: var(--font-weight-semibold); + } + + .review-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.6rem; + } + + .detail-field { + display: grid; + gap: 0.1rem; + } + + .detail-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--color-text-secondary); + } + + .detail-value { + font-size: 0.82rem; + color: var(--color-text-heading); + } + + .detail-value.code, + code.detail-value { + font-family: monospace; + font-size: 0.78rem; + word-break: break-all; + } + + .filter-preview { + margin: 0; + padding: 0.75rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + font-family: monospace; + font-size: 0.75rem; + color: var(--color-text-heading); + white-space: pre-wrap; + word-break: break-word; + max-height: 240px; + overflow-y: auto; + } + + /* ------------------------------------------------------------------ */ + /* Success state */ + /* ------------------------------------------------------------------ */ + + .success-body { + display: flex; + justify-content: center; + } + + .success-card { + max-width: 560px; + padding: 2rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-status-success-text, #22c55e); + border-radius: var(--radius-lg); + display: grid; + gap: 1rem; + text-align: center; + } + + .success-icon { + font-size: 2.5rem; + color: var(--color-status-success-text, #22c55e); + } + + .success-card h2 { + margin: 0; + font-size: 1.25rem; + font-weight: var(--font-weight-semibold); + } + + .success-domain-name { + margin: 0; + font-size: 1rem; + color: var(--color-text-secondary); + } + + .success-details { + display: grid; + gap: 0.5rem; + text-align: left; + } + + .success-instructions { + text-align: left; + } + + .success-instructions h4 { + margin: 0 0 0.5rem; + font-size: 0.85rem; + font-weight: var(--font-weight-semibold); + } + + .success-instructions ol { + margin: 0; + padding-left: 1.25rem; + font-size: 0.82rem; + color: var(--color-text-secondary); + display: grid; + gap: 0.25rem; + } + + /* ------------------------------------------------------------------ */ + /* Shared */ + /* ------------------------------------------------------------------ */ + + .banner { + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + font-size: 0.875rem; + } + + .banner--error { + border-color: var(--color-status-error-text, #ef4444); + color: var(--color-status-error-text, #ef4444); + } + + .btn { + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + border: none; + font-size: 0.85rem; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-primary { + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .btn-secondary { + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-heading); + } + + .btn-sm { + padding: 0.3rem 0.65rem; + font-size: 0.75rem; + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-secondary); + } + + .btn-xs { + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + background: var(--color-surface-tertiary, #374151); + color: var(--color-text-secondary); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + } + + .btn-accent { + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .btn-danger { + background: var(--color-status-error-text, #ef4444); + color: #fff; + } + + @media (max-width: 780px) { + .step-body--sources { + grid-template-columns: 1fr; + } + + .selection-summary { + position: static; + } + + .review-grid { + grid-template-columns: 1fr; + } + + .form-row { + flex-direction: column; + } + + .step-indicator { + flex-wrap: wrap; + } + } + `], +}) +export class MirrorDomainBuilderComponent implements OnInit { + private readonly sourceApi = inject(SourceManagementApi); + private readonly mirrorApi = inject(MirrorManagementApi); + private readonly router = inject(Router); + + // --------------------------------------------------------------------------- + // Step navigation + // --------------------------------------------------------------------------- + + readonly steps = [ + { index: 0, label: 'Select Sources' }, + { index: 1, label: 'Configure Domain' }, + { index: 2, label: 'Review & Create' }, + ]; + + readonly currentStep = signal(0); + + // --------------------------------------------------------------------------- + // Step 0: Source selection + // --------------------------------------------------------------------------- + + readonly catalog = signal([]); + readonly loading = signal(true); + readonly searchTerm = signal(''); + readonly selectedSourceIds = signal>(new Set()); + readonly collapsedCategories = signal>(new Set()); + + readonly filteredCatalog = computed(() => { + let items = this.catalog(); + const term = this.searchTerm().toLowerCase(); + if (term) { + items = items.filter( + (item) => + item.displayName.toLowerCase().includes(term) || + item.description.toLowerCase().includes(term) || + item.category.toLowerCase().includes(term) + ); + } + return items; + }); + + readonly filteredGrouped = computed((): CategoryGroup[] => { + const items = this.filteredCatalog(); + const map = new Map(); + + for (const item of items) { + const cat = CATEGORY_ORDER.includes(item.category) + ? item.category + : 'Other'; + const list = map.get(cat); + if (list) { + list.push(item); + } else { + map.set(cat, [item]); + } + } + + const groups: CategoryGroup[] = []; + for (const cat of CATEGORY_ORDER) { + const catItems = map.get(cat); + if (catItems && catItems.length > 0) { + groups.push({ category: cat, items: catItems }); + } + } + return groups; + }); + + readonly selectionByCategory = computed((): CategorySummary[] => { + const selected = this.selectedSourceIds(); + if (selected.size === 0) return []; + + const catalogItems = this.catalog(); + const counts = new Map(); + + for (const item of catalogItems) { + if (!selected.has(item.id)) continue; + const cat = CATEGORY_ORDER.includes(item.category) + ? item.category + : 'Other'; + counts.set(cat, (counts.get(cat) ?? 0) + 1); + } + + const result: CategorySummary[] = []; + for (const cat of CATEGORY_ORDER) { + const count = counts.get(cat); + if (count && count > 0) { + result.push({ category: cat, count }); + } + } + return result; + }); + + // --------------------------------------------------------------------------- + // Step 1: Domain configuration + // --------------------------------------------------------------------------- + + readonly domainId = signal(''); + readonly displayName = signal(''); + readonly exportFormat = signal('JSON'); + readonly indexRequestsPerHour = signal(DEFAULT_INDEX_REQUESTS_PER_HOUR); + readonly downloadRequestsPerHour = signal(DEFAULT_DOWNLOAD_REQUESTS_PER_HOUR); + readonly requireAuthentication = signal(false); + readonly signingEnabled = signal(false); + readonly signingAlgorithm = signal('HMAC-SHA256'); + readonly signingKeyId = signal(''); + + readonly exportFormats = EXPORT_FORMATS; + readonly signingAlgorithms = SIGNING_ALGORITHMS; + + // --------------------------------------------------------------------------- + // Step 2: Review & Create + // --------------------------------------------------------------------------- + + readonly generateAfterCreate = signal(false); + readonly creating = signal(false); + readonly createError = signal(null); + readonly createdDomain = signal(null); + readonly generateResult = signal(null); + + readonly resolvedFilterJson = computed(() => { + const selected = this.selectedSourceIds(); + const catalogItems = this.catalog(); + + const sourcesByCategory: Record = {}; + for (const item of catalogItems) { + if (!selected.has(item.id)) continue; + const cat = CATEGORY_ORDER.includes(item.category) + ? item.category + : 'Other'; + if (!sourcesByCategory[cat]) { + sourcesByCategory[cat] = []; + } + sourcesByCategory[cat].push(item.id); + } + + const filter = { + domainId: this.domainId(), + exportFormat: this.exportFormat(), + sourceCount: selected.size, + sourcesByCategory, + rateLimits: { + indexRequestsPerHour: this.indexRequestsPerHour(), + downloadRequestsPerHour: this.downloadRequestsPerHour(), + }, + requireAuthentication: this.requireAuthentication(), + signing: this.signingEnabled() + ? { algorithm: this.signingAlgorithm(), keyId: this.signingKeyId() } + : null, + }; + + return JSON.stringify(filter, null, 2); + }); + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + ngOnInit(): void { + this.loadCatalog(); + } + + // --------------------------------------------------------------------------- + // Step navigation + // --------------------------------------------------------------------------- + + goToStep(step: number): void { + if (step === 1 && this.selectedSourceIds().size === 0) return; + if (step === 2 && !this.isConfigValid()) return; + + // Auto-generate domain ID and display name when advancing to step 1 + if (step === 1 && !this.domainId()) { + this.autoGenerateIdentity(); + } + + this.currentStep.set(step); + } + + // --------------------------------------------------------------------------- + // Step 0 actions + // --------------------------------------------------------------------------- + + onSearchInput(event: Event): void { + const input = event.target as HTMLInputElement; + this.searchTerm.set(input.value); + } + + toggleSourceSelection(sourceId: string): void { + this.selectedSourceIds.update((current) => { + const next = new Set(current); + if (next.has(sourceId)) { + next.delete(sourceId); + } else { + next.add(sourceId); + } + return next; + }); + } + + toggleCategorySelection(category: string): void { + const itemsInCategory = this.getCategoryItems(category); + const selected = this.selectedSourceIds(); + const allSelected = itemsInCategory.every((item) => selected.has(item.id)); + + this.selectedSourceIds.update((current) => { + const next = new Set(current); + for (const item of itemsInCategory) { + if (allSelected) { + next.delete(item.id); + } else { + next.add(item.id); + } + } + return next; + }); + } + + isCategoryFullySelected(category: string): boolean { + const items = this.getCategoryItems(category); + if (items.length === 0) return false; + const selected = this.selectedSourceIds(); + return items.every((item) => selected.has(item.id)); + } + + isCategoryPartiallySelected(category: string): boolean { + const items = this.getCategoryItems(category); + if (items.length === 0) return false; + const selected = this.selectedSourceIds(); + const selectedCount = items.filter((item) => selected.has(item.id)).length; + return selectedCount > 0 && selectedCount < items.length; + } + + toggleCategoryCollapse(category: string): void { + this.collapsedCategories.update((current) => { + const next = new Set(current); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + } + + isCategoryCollapsed(category: string): boolean { + return this.collapsedCategories().has(category); + } + + selectByCategory(category: string): void { + const items = this.catalog().filter((item) => { + const cat = CATEGORY_ORDER.includes(item.category) + ? item.category + : 'Other'; + return cat === category; + }); + + this.selectedSourceIds.update((current) => { + const next = new Set(current); + for (const item of items) { + next.add(item.id); + } + return next; + }); + } + + selectAll(): void { + const allIds = this.catalog().map((item) => item.id); + this.selectedSourceIds.set(new Set(allIds)); + } + + clearSelection(): void { + this.selectedSourceIds.set(new Set()); + } + + // --------------------------------------------------------------------------- + // Step 1 validation + // --------------------------------------------------------------------------- + + isConfigValid(): boolean { + const id = this.domainId().trim(); + const name = this.displayName().trim(); + if (!id || !name) return false; + if (this.indexRequestsPerHour() < 1) return false; + if (this.downloadRequestsPerHour() < 1) return false; + if (this.signingEnabled() && !this.signingKeyId().trim()) return false; + return true; + } + + // --------------------------------------------------------------------------- + // Step 2 actions + // --------------------------------------------------------------------------- + + onCreate(): void { + this.creating.set(true); + this.createError.set(null); + + const request: CreateMirrorDomainRequest = { + domainId: this.domainId().trim(), + displayName: this.displayName().trim(), + sourceIds: [...this.selectedSourceIds()], + exportFormat: this.exportFormat(), + rateLimits: { + indexRequestsPerHour: this.indexRequestsPerHour(), + downloadRequestsPerHour: this.downloadRequestsPerHour(), + }, + requireAuthentication: this.requireAuthentication(), + signing: { + enabled: this.signingEnabled(), + algorithm: this.signingAlgorithm(), + keyId: this.signingKeyId().trim(), + }, + }; + + this.mirrorApi + .createDomain(request) + .pipe(take(1)) + .subscribe({ + next: (domain) => { + this.createdDomain.set(domain); + + if (this.generateAfterCreate()) { + this.mirrorApi + .generateDomain(domain.domainId) + .pipe(take(1)) + .subscribe({ + next: (result) => { + this.generateResult.set(result); + this.creating.set(false); + }, + error: () => { + // Domain was created but generate failed -- still show success + this.creating.set(false); + }, + }); + } else { + this.creating.set(false); + } + }, + error: (err) => { + const message = + err?.error?.message ?? + err?.error?.title ?? + err?.message ?? + 'Failed to create mirror domain. Please try again.'; + this.createError.set(message); + this.creating.set(false); + }, + }); + } + + navigateToSources(): void { + this.router.navigate(['/ops/integrations/advisory-vex-sources']); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private loadCatalog(): void { + this.loading.set(true); + this.sourceApi + .getCatalog() + .pipe(take(1)) + .subscribe({ + next: (response) => { + this.catalog.set(response.items ?? []); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + }, + }); + } + + private getCategoryItems(category: string): SourceCatalogItem[] { + return this.catalog().filter((item) => { + const cat = CATEGORY_ORDER.includes(item.category) + ? item.category + : 'Other'; + return cat === category; + }); + } + + private autoGenerateIdentity(): void { + const selected = this.selectedSourceIds(); + const catalogItems = this.catalog(); + const categories = new Set(); + + for (const item of catalogItems) { + if (!selected.has(item.id)) { + continue; + } + const cat = CATEGORY_ORDER.includes(item.category) + ? item.category + : 'Other'; + categories.add(cat); + } + + const catNames = [...categories].sort(); + const slug = catNames + .map((c) => c.toLowerCase()) + .join('-'); + const suffix = selected.size > 5 ? 'combined' : slug; + const domainSlug = `mirror-${suffix}-${selected.size}src`; + + this.domainId.set(domainSlug); + this.displayName.set( + `Mirror: ${catNames.join(', ')} (${selected.size} sources)` + ); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-management.api.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-management.api.ts new file mode 100644 index 000000000..73a6ab361 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-management.api.ts @@ -0,0 +1,187 @@ +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'; + +// --------------------------------------------------------------------------- +// DTOs +// --------------------------------------------------------------------------- + +export type MirrorMode = 'Direct' | 'Mirror' | 'Hybrid'; + +export interface MirrorDomainSourceRef { + sourceId: string; + displayName: string; + category: string; +} + +export interface MirrorDomainRateLimits { + indexRequestsPerHour: number; + downloadRequestsPerHour: number; +} + +export interface MirrorDomainSigning { + enabled: boolean; + algorithm: string; + keyId: string; +} + +export interface CreateMirrorDomainRequest { + domainId: string; + displayName: string; + sourceIds: string[]; + exportFormat: string; + rateLimits: MirrorDomainRateLimits; + requireAuthentication: boolean; + signing: MirrorDomainSigning; +} + +export interface MirrorDomainResponse { + id: string; + domainId: string; + displayName: string; + sourceIds: string[]; + exportFormat: string; + rateLimits: MirrorDomainRateLimits; + requireAuthentication: boolean; + signing: MirrorDomainSigning; + domainUrl: string; + createdAt: string; + status: string; +} + +export interface MirrorDomainGenerateResponse { + domainId: string; + jobId: string; + status: string; + startedAt: string; +} + +export interface MirrorDomainConfigResponse { + domainId: string; + displayName: string; + sourceIds: string[]; + exportFormat: string; + rateLimits: MirrorDomainRateLimits; + requireAuthentication: boolean; + signing: MirrorDomainSigning; + resolvedFilter: Record; +} + +export interface MirrorDomainListResponse { + domains: MirrorDomainResponse[]; + totalCount: number; +} + +export interface MirrorConfigResponse { + mode: MirrorMode; + consumerMirrorUrl: string | null; + consumerConnected: boolean; + lastConsumerSync: string | null; +} + +export interface MirrorHealthSummary { + totalDomains: number; + freshCount: number; + staleCount: number; + neverGeneratedCount: number; + totalAdvisoryCount: number; +} + +export interface MirrorDomainEndpointDto { + path: string; + method: string; + description: string; +} + +export interface MirrorDomainEndpointsResponse { + domainId: string; + endpoints: MirrorDomainEndpointDto[]; +} + +// --------------------------------------------------------------------------- +// API service +// --------------------------------------------------------------------------- + +@Injectable({ providedIn: 'root' }) +export class MirrorManagementApi { + private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + private readonly baseUrl = '/api/v1/mirror'; + + // ── Mirror Config ────────────────────────────────────────────────────────── + + getConfig(): Observable { + return this.http.get(`${this.baseUrl}/config`, { + headers: this.buildHeaders(), + }); + } + + getHealthSummary(): Observable { + return this.http.get(`${this.baseUrl}/health`, { + headers: this.buildHeaders(), + }); + } + + // ── Domains ──────────────────────────────────────────────────────────────── + + listDomains(): Observable { + return this.http.get(`${this.baseUrl}/domains`, { + headers: this.buildHeaders(), + }); + } + + getDomain(domainId: string): Observable { + return this.http.get( + `${this.baseUrl}/domains/${encodeURIComponent(domainId)}`, + { headers: this.buildHeaders() } + ); + } + + createDomain(request: CreateMirrorDomainRequest): Observable { + return this.http.post(`${this.baseUrl}/domains`, request, { + headers: this.buildHeaders(), + }); + } + + deleteDomain(domainId: string): Observable { + return this.http.delete( + `${this.baseUrl}/domains/${encodeURIComponent(domainId)}`, + { headers: this.buildHeaders() } + ); + } + + generateDomain(domainId: string): Observable { + return this.http.post( + `${this.baseUrl}/domains/${encodeURIComponent(domainId)}/generate`, + null, + { headers: this.buildHeaders() } + ); + } + + getDomainConfig(domainId: string): Observable { + return this.http.get( + `${this.baseUrl}/domains/${encodeURIComponent(domainId)}/config`, + { headers: this.buildHeaders() } + ); + } + + getDomainEndpoints(domainId: string): Observable { + return this.http.get( + `${this.baseUrl}/domains/${encodeURIComponent(domainId)}/endpoints`, + { 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/integrations/advisory-vex-sources/source-management.api.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/source-management.api.ts new file mode 100644 index 000000000..6b8d405ea --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/source-management.api.ts @@ -0,0 +1,147 @@ +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 SourceCatalogItem { + id: string; + displayName: string; + category: string; + type: string; + description: string; + baseEndpoint: string; + requiresAuth: boolean; + credentialEnvVar?: string | null; + credentialUrl?: string | null; + documentationUrl?: string | null; + defaultPriority: number; + regions: string[]; + tags: string[]; + enabledByDefault: boolean; +} + +export interface SourceCatalogResponse { + items: SourceCatalogItem[]; + totalCount: number; +} + +export interface SourceStatusItem { + sourceId: string; + enabled: boolean; + lastCheck?: SourceConnectivityResultDto | null; +} + +export interface SourceStatusResponse { + sources: SourceStatusItem[]; +} + +export interface SourceConnectivityResultDto { + sourceId: string; + status: string; + checkedAt: string; + latency?: string | null; + errorMessage?: string | null; + errorCode?: string | null; + httpStatusCode?: number | null; + possibleReasons: string[]; + remediationSteps: RemediationStepDto[]; + isHealthy: boolean; +} + +export interface RemediationStepDto { + order: number; + description: string; + command?: string | null; + commandType: string; + documentationUrl?: string | null; +} + +export interface BatchSourceRequest { + sourceIds: string[]; +} + +export interface BatchSourceResultItem { + sourceId: string; + success: boolean; + error?: string | null; +} + +export interface BatchSourceResponse { + results: BatchSourceResultItem[]; +} + +@Injectable({ providedIn: 'root' }) +export class SourceManagementApi { + private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + private readonly baseUrl = '/api/v1/sources'; + + getCatalog(): Observable { + return this.http.get(`${this.baseUrl}/catalog`, { + headers: this.buildHeaders(), + }); + } + + getStatus(): Observable { + return this.http.get(`${this.baseUrl}/status`, { + headers: this.buildHeaders(), + }); + } + + enableSource(sourceId: string): Observable { + return this.http.post(`${this.baseUrl}/${encodeURIComponent(sourceId)}/enable`, null, { + headers: this.buildHeaders(), + }); + } + + disableSource(sourceId: string): Observable { + return this.http.post(`${this.baseUrl}/${encodeURIComponent(sourceId)}/disable`, null, { + headers: this.buildHeaders(), + }); + } + + checkAll(): Observable { + return this.http.post(`${this.baseUrl}/check`, null, { + headers: this.buildHeaders(), + }); + } + + checkSource(sourceId: string): Observable { + return this.http.post( + `${this.baseUrl}/${encodeURIComponent(sourceId)}/check`, + null, + { headers: this.buildHeaders() } + ); + } + + batchEnable(sourceIds: string[]): Observable { + return this.http.post(`${this.baseUrl}/batch-enable`, { sourceIds } as BatchSourceRequest, { + headers: this.buildHeaders(), + }); + } + + batchDisable(sourceIds: string[]): Observable { + return this.http.post(`${this.baseUrl}/batch-disable`, { sourceIds } as BatchSourceRequest, { + headers: this.buildHeaders(), + }); + } + + getCheckResult(sourceId: string): Observable { + return this.http.get( + `${this.baseUrl}/${encodeURIComponent(sourceId)}/check-result`, + { 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/integrations/integrations-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/integrations-hub.component.ts index dba9e9ed6..ff7aa0803 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/integrations-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/integrations-hub.component.ts @@ -103,6 +103,19 @@ import {

No runtime-host connector plugins are currently available.

+ +
+
+
+

Advisory & VEX Sources

+

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

+
+ +
+

Manage NVD, OSV, GHSA, vendor CERTs, CSAF feeds, and StellaOps mirrors from one catalog.

+
} } @else { @@ -282,6 +295,12 @@ export class IntegrationsHubComponent implements OnInit { }); } + openAdvisorySourceCatalog(): void { + void this.router.navigate(this.integrationCommands('advisory-vex-sources'), { + queryParamsHandling: 'merge', + }); + } + openWizard(type: IntegrationOnboardingType): void { if (this.providersForType(type).length === 0) { return; diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts b/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts index e0614e97d..b64aedc6f 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts @@ -5,7 +5,7 @@ import { SupportedProviderInfo, } from '../../integration-hub/integration.models'; -export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host'; +export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host' | 'advisory-vex'; export type WizardStep = 'provider' | 'auth' | 'scope' | 'schedule' | 'preflight' | 'review'; export interface ProviderField { diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts index 26a7c23e5..419ef4d43 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts @@ -4,6 +4,7 @@ import { RouterLink } from '@angular/router'; import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; import { forkJoin, of } from 'rxjs'; import { catchError, map, take } from 'rxjs/operators'; +import { AdvisorySourcesApi, AdvisorySourceListItemDto } from './advisory-sources.api'; import { PlatformContextStore } from '../../core/context/platform-context.store'; @@ -64,8 +65,8 @@ interface PlatformListResponse {