From fb46a927ad64cbae8b769da558e632b4c1b47dd7 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 17 Feb 2026 00:51:35 +0200 Subject: [PATCH] save changes --- devops/compose/docker-compose.stella-ops.yml | 31 +- ...ff Stack for Source and Binary Patching.md | 59 +++ ...ess deterministic replay across distros.md | 24 + docs/README.md | 2 + docs/hybrid-diff-patching.md | 45 ++ ..._BinaryIndex_hybrid_diff_patch_pipeline.md | 126 +++++ ..._ebpf_micro_witness_determinism_profile.md | 102 ++++ docs/modules/binary-index/README.md | 3 +- docs/modules/binary-index/architecture.md | 3 +- .../modules/binary-index/hybrid-diff-stack.md | 163 +++++++ docs/modules/signals/README.md | 1 + .../ebpf-micro-witness-determinism-profile.md | 124 +++++ docs/product/README.md | 1 + .../product/ebpf-micro-witness-determinism.md | 36 ++ .../HybridDiffContracts.cs | 435 ++++++++++++++++++ .../Program.cs | 7 + .../Extensions/NotifyPersistenceExtensions.cs | 2 +- .../PlatformAnalyticsQueryExecutor.cs | 11 +- src/Policy/StellaOps.Policy.Engine/Program.cs | 11 + .../StellaOps.Policy.Gateway/Program.cs | 17 + .../IdentityHeaderPolicyMiddleware.cs | 61 ++- .../StellaOps.Gateway.WebService/Program.cs | 20 +- .../appsettings.json | 32 +- .../Configuration/StellaOpsRoute.cs | 7 + .../Auth/HeaderScopeAuthorizer.cs | 27 +- .../Schema/RuntimeCallEvent.cs | 41 ++ src/Web/StellaOps.Web/angular.json | 1 + src/Web/StellaOps.Web/debug-auth.mjs | 114 +++++ src/Web/StellaOps.Web/probe-services.mjs | 65 +++ src/Web/StellaOps.Web/proxy.conf.json | 62 +-- src/Web/StellaOps.Web/scan-pages.mjs | 105 +++++ src/Web/StellaOps.Web/scheduler-debug.png | Bin 0 -> 81099 bytes src/Web/StellaOps.Web/src/app/app.config.ts | 302 +++++++++++- .../src/app/core/api/abac-overlay.client.ts | 7 +- .../src/app/core/api/advisories.client.ts | 5 +- .../src/app/core/api/analytics.client.ts | 124 ++++- .../src/app/core/api/aoc.client.ts | 129 +++++- .../app/core/api/attestation-chain.client.ts | 2 +- .../src/app/core/api/audit-bundles.client.ts | 25 +- .../app/core/api/authority-admin.client.ts | 27 ++ .../src/app/core/api/console-export.client.ts | 5 +- .../src/app/core/api/console-search.client.ts | 15 +- .../src/app/core/api/console-status.client.ts | 5 +- .../src/app/core/api/console-vex.client.ts | 5 +- .../src/app/core/api/console-vuln.client.ts | 5 +- .../src/app/core/api/cvss.client.ts | 6 +- .../src/app/core/api/evidence.client.ts | 111 ++++- .../app/core/api/exception-events.client.ts | 5 +- .../src/app/core/api/exception.client.ts | 24 +- .../src/app/core/api/export-center.client.ts | 5 +- .../src/app/core/api/feed-mirror.client.ts | 190 +++++++- .../app/core/api/findings-ledger.client.ts | 10 +- .../src/app/core/api/first-signal.client.ts | 5 +- .../src/app/core/api/graph-platform.client.ts | 5 +- .../core/api/orchestrator-control.client.ts | 5 +- .../src/app/core/api/orchestrator.client.ts | 5 +- .../app/core/api/platform-health.models.ts | 3 +- .../app/core/api/policy-exceptions.client.ts | 5 +- .../src/app/core/api/policy-gates.client.ts | 188 +++++++- .../app/core/api/policy-simulation.client.ts | 5 +- .../src/app/core/api/release.client.ts | 63 ++- .../src/app/core/api/risk-http.client.ts | 5 +- .../src/app/core/api/scheduler.client.ts | 71 ++- .../src/app/core/api/score.client.ts | 4 +- .../app/core/api/security-findings.client.ts | 53 ++- .../app/core/api/security-overview.client.ts | 44 +- .../src/app/core/api/vex-consensus.client.ts | 10 +- .../src/app/core/api/vex-decisions.client.ts | 8 +- .../src/app/core/api/vex-evidence.client.ts | 5 +- .../src/app/core/api/vex-hub.client.ts | 27 +- .../api/vuln-export-orchestrator.service.ts | 5 +- .../app/core/api/vulnerability-http.client.ts | 17 +- .../src/app/core/auth/auth-storage.service.ts | 36 +- .../StellaOps.Web/src/app/core/auth/scopes.ts | 30 ++ .../core/auth/tenant-activation.service.ts | 11 + .../app/core/auth/tenant-http.interceptor.ts | 10 +- .../src/app/core/config/app-config.service.ts | 53 +++ .../app/core/config/backend-probe.service.ts | 67 ++- .../core/services/fix-verification.service.ts | 4 +- .../admin-notifications.component.ts | 4 +- .../channel-management.component.ts | 8 +- .../delivery-analytics.component.ts | 8 +- .../components/delivery-history.component.ts | 8 +- .../components/escalation-config.component.ts | 8 +- .../notification-dashboard.component.ts | 10 +- .../notification-preview.component.ts | 6 +- .../notification-rule-editor.component.ts | 4 +- .../notification-rule-list.component.ts | 4 +- .../operator-override-management.component.ts | 6 +- .../components/operator-override.component.ts | 4 +- .../quiet-hours-config.component.ts | 6 +- .../components/rule-simulator.component.ts | 6 +- .../components/template-editor.component.ts | 4 +- .../components/throttle-config.component.ts | 8 +- .../request-exception-modal.component.ts | 2 +- .../features/auth/auth-callback.component.ts | 6 +- .../binary-index/patch-map.component.ts | 10 +- .../components/compare-view.component.ts | 2 +- .../components/trust-indicators.component.ts | 11 +- .../audit/audit-log.component.ts | 2 +- .../tenants/tenants-list.component.ts | 2 +- .../control-plane-dashboard.component.ts | 19 +- .../evidence-viewer.component.ts | 2 +- .../export-dialog/export-dialog.component.ts | 2 +- .../remediation-panel.component.ts | 2 +- .../features/doctor/services/doctor.client.ts | 3 +- .../evidence-bundles.component.ts | 120 +++-- .../provenance-visualization.component.ts | 14 +- .../audit-bundle-create-modal.component.ts | 4 +- .../feed-mirror/airgap-export.component.ts | 3 +- .../feed-mirror/airgap-import.component.ts | 9 +- .../feed-mirror-dashboard.component.ts | 3 +- .../feed-mirror/feed-mirror.component.ts | 3 +- .../feed-mirror/mirror-detail.component.ts | 2 +- .../snapshot-selector.component.ts | 3 +- .../feed-mirror/version-lock.component.ts | 3 +- .../detail/evidence-panel.component.ts | 2 +- .../detail/finding-detail-layout.component.ts | 4 +- .../features/graph/graph-canvas.component.ts | 4 +- .../graph/graph-explorer.component.html | 6 +- .../graph/graph-explorer.component.ts | 2 - .../features/graph/graph-filters.component.ts | 18 +- .../graph/graph-hotkey-help.component.ts | 4 +- .../graph/graph-overlays.component.ts | 18 +- .../graph/graph-side-panels.component.ts | 18 +- .../integration-list.component.ts | 19 +- .../lineage-compare-panel.component.ts | 4 +- .../lineage-compare.component.ts | 4 +- .../lineage-detail-panel.component.ts | 4 +- .../lineage-export-buttons.component.ts | 6 +- .../lineage-export-dialog.component.ts | 4 +- .../lineage-hover-card.component.ts | 2 +- .../lineage-mobile-compare.component.ts | 20 +- .../lineage-provenance-chips.component.ts | 2 +- .../lineage-provenance-compare.component.ts | 4 +- .../lineage-sbom-diff.component.ts | 2 +- .../lineage-timeline-slider.component.ts | 12 +- .../lineage-vex-diff.component.ts | 2 +- .../lineage-why-safe-panel.component.ts | 4 +- .../timeline-slider.component.ts | 2 +- .../services/lineage-export.service.ts | 2 +- .../components/bundle-management.component.ts | 2 +- .../components/jwks-management.component.ts | 2 +- .../verification-center.component.ts | 2 +- .../platform-health-dashboard.component.ts | 20 +- .../conflict-resolution-wizard.component.ts | 2 - .../governance-audit.component.ts | 2 - .../impact-preview.component.ts | 2 - .../policy-conflict-dashboard.component.ts | 2 - .../policy-validator.component.ts | 2 - .../risk-budget-config.component.ts | 2 - .../risk-budget-dashboard.component.ts | 2 - .../risk-profile-editor.component.ts | 2 - .../risk-profile-list.component.ts | 2 - .../schema-playground.component.ts | 2 - .../sealed-mode-control.component.ts | 2 - .../sealed-mode-overrides.component.ts | 2 - .../staleness-config.component.ts | 2 - .../trust-weighting.component.ts | 2 - .../evidence-chain-viewer.component.ts | 4 +- .../policy/policy-studio.component.ts | 4 +- .../proof-detail-panel.component.html | 6 +- .../verification-badge.component.ts | 12 +- .../proof-chain/proof-chain.component.html | 4 +- .../proof-chain/proof-chain.component.ts | 4 +- .../confidence-breakdown.component.html | 3 +- .../confidence-breakdown.component.ts | 14 +- .../confidence-factor-chip.component.ts | 18 +- .../proof-studio-container.component.html | 4 +- .../what-if-slider.component.html | 2 +- .../proof/proof-ledger-view.component.html | 12 +- .../proof/proof-replay-dashboard.component.ts | 53 ++- .../proof/score-comparison-view.component.ts | 16 +- .../proof-replay-dashboard.component.ts | 4 +- .../quota-alert-config.component.ts | 4 +- .../quota-forecast.component.ts | 4 +- .../path-viewer/path-viewer.component.html | 3 +- .../path-viewer/path-viewer.component.ts | 14 +- .../risk-drift-card.component.html | 6 +- .../risk-drift-card.component.ts | 10 +- .../reachability/poe-drawer.component.ts | 18 +- .../reachability-explain-widget.component.ts | 49 +- .../reachability-explain.component.html | 2 +- .../reachability/witness-page.component.ts | 20 +- .../components/plan-editor.component.ts | 2 +- .../freeze-window-editor.component.ts | 4 +- .../target-list/target-list.component.ts | 2 +- .../environment-detail.component.ts | 6 +- .../environment-list.component.ts | 6 +- .../evidence-detail.component.ts | 18 +- .../evidence-list/evidence-list.component.ts | 8 +- .../create-release.component.ts | 10 +- .../release-detail.component.ts | 14 +- .../release-list/release-list.component.ts | 12 +- .../releases/release-flow.component.ts | 3 +- .../source-wizard/source-wizard.component.ts | 22 +- .../sources-list/sources-list.component.html | 24 +- .../performance-baseline.component.ts | 6 +- .../scheduler-ops/scheduler-runs.component.ts | 86 +--- .../scores/score-comparison.component.ts | 2 +- .../alert-destination-config.component.ts | 2 +- .../secret-detection-settings.component.ts | 2 +- .../secret-findings-list.component.ts | 24 +- .../security/artifacts-page.component.ts | 25 +- .../security/exceptions-page.component.ts | 44 +- .../security-overview-page.component.ts | 2 +- .../vulnerability-detail-page.component.ts | 14 +- .../admin/admin-settings-page.component.ts | 124 ++++- .../settings/ai-preferences.component.ts | 8 +- .../notifications-settings-page.component.ts | 6 +- .../slo-monitoring/slo-dashboard.component.ts | 4 +- .../slo-monitoring/slo-detail.component.ts | 8 +- .../sources/aoc-dashboard.component.html | 4 +- .../sources/aoc-dashboard.component.ts | 11 +- .../sources/violation-detail.component.ts | 3 +- .../ai-recommendation-panel.component.ts | 12 +- .../bulk-action-modal.component.ts | 20 +- .../export-evidence-button.component.ts | 6 +- .../gating-explainer.component.ts | 37 +- .../playbook-suggestion.component.ts | 2 +- .../provenance-breadcrumb.component.ts | 42 +- .../reachability-context.component.ts | 6 +- .../replay-command.component.ts | 18 +- .../triage-list/triage-list.component.ts | 16 +- .../unknowns-list.component.html | 4 +- .../vex-history/vex-history.component.ts | 12 +- .../vex-trust-display.component.ts | 16 +- .../features/triage/models/gating.model.ts | 14 +- .../triage/triage-workspace.component.html | 4 +- .../trust-admin/trust-analytics.component.ts | 24 +- .../determinization-review.component.ts | 2 +- .../unknown-detail.component.ts | 2 +- .../unknowns/unknowns-queue.component.ts | 67 +-- .../app/features/vex-hub/vex-hub.component.ts | 36 +- .../vex-timeline/vex-timeline.component.ts | 15 +- .../models/vex-timeline.models.ts | 12 +- .../citation-link/citation-link.component.ts | 35 +- .../evidence-subgraph.component.ts | 104 +++-- .../evidence-tree/evidence-tree.component.ts | 55 +-- .../filter-preset-pills.component.ts | 2 +- .../triage-card/triage-card.component.ts | 28 +- .../triage-filters.component.ts | 35 +- .../verdict-explanation.component.ts | 34 +- .../trust-algebra/claim-table.component.ts | 25 +- .../trust-algebra/policy-chips.component.ts | 7 +- .../trust-algebra/replay-button.component.ts | 16 +- .../trust-algebra/trust-algebra.component.ts | 2 +- .../vuln-triage-dashboard.component.ts | 4 +- .../vulnerability-explorer.component.html | 8 +- .../vulnerability-explorer.component.ts | 4 +- .../welcome/welcome-page.component.ts | 16 +- .../auditor-workspace.component.ts | 7 +- .../models/auditor-workspace.models.ts | 8 +- .../developer-workspace.component.ts | 23 +- .../models/developer-workspace.models.ts | 24 +- .../app-sidebar/app-sidebar.component.ts | 11 +- .../sidebar-nav-group.component.ts | 120 ++++- .../app-sidebar/sidebar-nav-item.component.ts | 15 +- .../global-search/global-search.component.ts | 23 +- .../ai/ai-assist-panel.component.ts | 2 +- .../ai/ai-authority-badge.component.ts | 9 +- .../ai/ai-exploitability-chip.component.ts | 11 +- .../components/ai/ai-summary.component.ts | 8 +- .../ai/ask-stella-panel.component.ts | 8 +- .../ai/llm-unavailable.component.ts | 4 +- .../components/approval-button.component.ts | 10 +- .../components/attestation-node.component.ts | 17 +- .../attestation-viewer.component.ts | 8 +- .../determinism-badge.component.html | 2 +- .../dsse-envelope-viewer.component.ts | 42 +- .../components/evidence-drawer.component.ts | 6 +- .../evidence-drawer.component.ts | 6 +- .../components/exception-badge.component.ts | 2 +- .../components/exception-explain.component.ts | 4 +- .../feature-card/feature-card.component.ts | 2 +- .../components/finding-list.component.ts | 10 +- .../components/finding-row.component.ts | 9 +- .../components/fix-verdict-badge.component.ts | 22 +- .../function-diff/function-diff.component.ts | 2 +- .../shared/components/gate-badge.component.ts | 17 +- .../graph-diff/graph-diff.component.ts | 10 +- .../graph-diff/graph-split-view.component.ts | 2 +- .../components/input-manifest.component.ts | 18 +- .../manifest-validator.component.ts | 2 +- .../components/metrics-dashboard.component.ts | 21 +- .../offline-verification.component.ts | 56 ++- .../path-visualization.component.ts | 10 +- .../plain-language-toggle.component.ts | 2 +- .../proof-chain-viewer.component.ts | 6 +- .../proof-spine/proof-badges-row.component.ts | 2 +- .../shared/components/proof-tree.component.ts | 61 ++- .../shared/components/rekor-link.component.ts | 8 +- .../resolution-chip.component.ts | 2 +- .../components/risk-drift-card.component.ts | 20 +- .../components/score-breakdown.component.ts | 6 +- .../stats-card/stats-card.component.ts | 2 +- .../components/timeline-event.component.ts | 63 ++- .../triage/triage-card.component.ts | 35 +- .../components/unknown-chip.component.ts | 2 +- .../unwitnessed-advisory.component.ts | 4 +- .../user-menu/user-menu.component.scss | 4 +- .../components/vex-status-chip.component.ts | 2 +- .../vex-trust-popover.component.ts | 2 +- .../witness-comparison.component.ts | 2 +- .../components/witness-modal.component.ts | 12 +- .../evidence-link/evidence-link.component.ts | 4 +- .../witness-path-preview.component.ts | 2 +- .../evidence-packet-drawer.component.ts | 2 +- .../finding-detail-drawer.component.ts | 8 +- .../ui/filter-bar/filter-bar.component.ts | 2 +- .../witness-viewer.component.ts | 27 +- .../src/assets/icons/apple-touch-icon.png | Bin 0 -> 27396 bytes .../src/assets/icons/icon-128x128.png | Bin 0 -> 16053 bytes .../src/assets/icons/icon-16x16.png | Bin 0 -> 849 bytes .../src/assets/icons/icon-192x192.png | Bin 0 -> 30941 bytes .../src/assets/icons/icon-32x32.png | Bin 0 -> 2196 bytes .../src/assets/icons/icon-48x48.png | Bin 0 -> 4001 bytes .../src/assets/icons/icon-512x512.png | Bin 0 -> 189646 bytes .../src/assets/icons/icon-96x96.png | Bin 0 -> 11384 bytes src/Web/StellaOps.Web/src/config/config.json | 2 +- src/Web/StellaOps.Web/src/favicon.ico | Bin 15086 -> 7100 bytes src/Web/StellaOps.Web/src/index.html | 7 +- .../StellaOps.Web/src/manifest.webmanifest | 33 ++ .../src/styles/tokens/_colors.scss | 31 +- 324 files changed, 4976 insertions(+), 1499 deletions(-) create mode 100644 docs-archived/product/advisories/16-Feb-2026 - Hybrid Diff Stack for Source and Binary Patching.md create mode 100644 docs-archived/product/advisories/16-Feb-2026 - eBPF micro-witness deterministic replay across distros.md create mode 100644 docs/hybrid-diff-patching.md create mode 100644 docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md create mode 100644 docs/implplan/SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile.md create mode 100644 docs/modules/binary-index/hybrid-diff-stack.md create mode 100644 docs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.md create mode 100644 docs/product/ebpf-micro-witness-determinism.md create mode 100644 src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/HybridDiffContracts.cs create mode 100644 src/Web/StellaOps.Web/debug-auth.mjs create mode 100644 src/Web/StellaOps.Web/probe-services.mjs create mode 100644 src/Web/StellaOps.Web/scan-pages.mjs create mode 100644 src/Web/StellaOps.Web/scheduler-debug.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/apple-touch-icon.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/icon-128x128.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/icon-16x16.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/icon-192x192.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/icon-32x32.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/icon-48x48.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/icon-512x512.png create mode 100644 src/Web/StellaOps.Web/src/assets/icons/icon-96x96.png create mode 100644 src/Web/StellaOps.Web/src/manifest.webmanifest diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index 0d1a360f6..0553c2648 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -265,10 +265,12 @@ services: ConnectionStrings__Redis: "cache.stella-ops.local:6379" Platform__Authority__Issuer: "https://stella-ops.local" Platform__Authority__RequireHttpsMetadata: "false" + Platform__Authority__BypassNetworks__0: "172.19.0.0/16" Platform__Storage__Driver: "postgres" Platform__Storage__PostgresConnectionString: *postgres-connection Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback" Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/" + Platform__EnvironmentSettings__Scope: "openid profile email ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit" STELLAOPS_ROUTER_URL: "http://router.stella-ops.local" STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local" STELLAOPS_AUTHORITY_URL: "http://authority.stella-ops.local" @@ -381,10 +383,12 @@ services: restart: unless-stopped depends_on: *depends-infra environment: - ASPNETCORE_URLS: "http://+:8080" + ASPNETCORE_URLS: "http://+:80;http://+:8080" <<: *kestrel-cert ConnectionStrings__Default: *postgres-connection ConnectionStrings__Redis: "cache.stella-ops.local:6379" + Gateway__Auth__Authority__Issuer: "https://authority.stella-ops.local/" + Gateway__Auth__Authority__RequireHttpsMetadata: "false" volumes: - *cert-volume ports: @@ -743,6 +747,8 @@ services: <<: *kestrel-cert ConnectionStrings__Default: *postgres-connection ConnectionStrings__Redis: "cache.stella-ops.local:6379" + Postgres__ConnectionString: *postgres-connection + Postgres__SchemaName: "vexhub" volumes: - *cert-volume ports: @@ -818,8 +824,13 @@ services: <<: *kestrel-cert STELLAOPS_POLICY_ENGINE_Postgres__Policy__ConnectionString: *postgres-connection STELLAOPS_POLICY_ENGINE_ConnectionStrings__Redis: "cache.stella-ops.local:6379" - STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__Authority: "http://authority.stella-ops.local" + STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__Authority: "https://authority.stella-ops.local/" + STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__MetadataAddress: "http://authority.stella-ops.local/.well-known/openid-configuration" STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__RequireHttpsMetadata: "false" + STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__Audiences__0: "/scanner" + STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__BypassNetworks__0: "172.19.0.0/16" + STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__BypassNetworks__1: "127.0.0.1/32" + STELLAOPS_POLICY_ENGINE_PolicyEngine__ResourceServer__BypassNetworks__2: "::1/128" volumes: - *cert-volume ports: @@ -845,8 +856,14 @@ services: <<: *kestrel-cert ConnectionStrings__Default: *postgres-connection ConnectionStrings__Redis: "cache.stella-ops.local:6379" + Postgres__Policy__ConnectionString: *postgres-connection PolicyGateway__ResourceServer__Authority: "http://authority.stella-ops.local" PolicyGateway__ResourceServer__RequireHttpsMetadata: "false" + PolicyGateway__ResourceServer__BypassNetworks__0: "172.19.0.0/16" + # Bootstrap-prefixed vars (read by StellaOpsConfigurationBootstrapper before DI) + STELLAOPS_POLICY_GATEWAY_PolicyGateway__ResourceServer__Authority: "http://authority.stella-ops.local" + STELLAOPS_POLICY_GATEWAY_PolicyGateway__ResourceServer__RequireHttpsMetadata: "false" + STELLAOPS_POLICY_GATEWAY_Postgres__Policy__ConnectionString: *postgres-connection volumes: - *cert-volume ports: @@ -1012,6 +1029,7 @@ services: <<: *kestrel-cert ConnectionStrings__Default: *postgres-connection ConnectionStrings__Redis: "cache.stella-ops.local:6379" + Scheduler__Authority__Enabled: "false" volumes: - *cert-volume tmpfs: @@ -1224,6 +1242,7 @@ services: findings__ledger__Database__ConnectionString: *postgres-connection findings__ledger__Authority__Issuer: "http://authority.stella-ops.local" findings__ledger__Authority__RequireHttpsMetadata: "false" + findings__ledger__Authority__BypassNetworks__0: "172.19.0.0/16" findings__ledger__Attachments__EncryptionKey: "IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiI=" findings__ledger__Attachments__SignedUrlBase: "http://findings.stella-ops.local/attachments" findings__ledger__Attachments__SignedUrlSecret: "dev-signed-url-secret" @@ -1254,6 +1273,9 @@ services: <<: *kestrel-cert ConnectionStrings__Default: *postgres-connection ConnectionStrings__Redis: "cache.stella-ops.local:6379" + Doctor__Authority__Issuer: "http://authority.stella-ops.local" + Doctor__Authority__RequireHttpsMetadata: "false" + Doctor__Authority__BypassNetworks__0: "172.19.0.0/16" volumes: - *cert-volume ports: @@ -1373,6 +1395,10 @@ services: NOTIFY_NOTIFY__STORAGE__CONNECTIONSTRING: *postgres-connection NOTIFY_NOTIFY__STORAGE__DATABASE: "notify" NOTIFY_NOTIFY__PLUGINS__BASEDIRECTORY: "/app" + NOTIFY_NOTIFY__AUTHORITY__ENABLED: "false" + NOTIFY_NOTIFY__AUTHORITY__ALLOWANONYMOUSFALLBACK: "true" + NOTIFY_NOTIFY__AUTHORITY__DEVELOPMENTSIGNINGKEY: "StellaOps-Development-Key-NotifyService-2026!!" + NOTIFY_Postgres__Notify__ConnectionString: *postgres-connection Postgres__Notify__ConnectionString: *postgres-connection volumes: - ../../etc/notify:/app/etc/notify:ro @@ -1642,6 +1668,7 @@ services: ConnectionStrings__Redis: "cache.stella-ops.local:6379" Authority__ResourceServer__Authority: "http://authority.stella-ops.local" Authority__ResourceServer__RequireHttpsMetadata: "false" + Authority__ResourceServer__BypassNetworks__0: "172.19.0.0/16" volumes: - *cert-volume ports: diff --git a/docs-archived/product/advisories/16-Feb-2026 - Hybrid Diff Stack for Source and Binary Patching.md b/docs-archived/product/advisories/16-Feb-2026 - Hybrid Diff Stack for Source and Binary Patching.md new file mode 100644 index 000000000..1c5cd0e54 --- /dev/null +++ b/docs-archived/product/advisories/16-Feb-2026 - Hybrid Diff Stack for Source and Binary Patching.md @@ -0,0 +1,59 @@ +# 16-Feb-2026 - Hybrid Diff Stack for Source and Binary Patching + +## Source +- Origin: user-submitted advisory in chat (2026-02-16). +- Theme: versioning and patching across source + binaries using a hybrid + source-symbol-binary diff pipeline. + +## Advisory summary + +Proposed architecture: + +1. Source-level AST semantic edit scripts with symbol anchors. +2. Build-time mapping from source edits to symbol ranges using DWARF/PDB and + build-id metadata. +3. Binary normalization followed by compact per-symbol delta generation. +4. Signed packaging (DSSE + transparency logging) and policy gates based on + function-level change scope. + +Proposed deliverables: + +- Builder outputs: `symbol_map.json`, `build_id`, normalized streams. +- Differ outputs: `symbol_patch_plan.json`, per-symbol delta payloads, + `patch_manifest.json`. +- Verifier checks for build-id match, boundary-safe dry-run apply, and + source-anchor reconciliation. +- Evidence Locker schema extension for hybrid diff artifacts. + +## Review result + +Outcome: **Accepted as partially implemented and requiring additional delivery**. + +Already implemented in repository: + +- Normalized ELF segment hashing and normalization passes in BinaryIndex. +- DeltaSig attestation model + CLI flow for signature extraction/sign/verify. +- Symbol manifest model carrying debug and source metadata. + +Gaps identified: + +- No first-class AST semantic edit script artifact pipeline. +- No canonical source-to-symbol map artifact contract emitted at build stage. +- No unified symbol patch plan manifest linking AST anchors to normalized + per-symbol delta artifacts. +- Function boundary/address accuracy still incomplete in parts of DeltaSig + function delta generation. + +## Translated artifacts + +- High-level doc: `docs/hybrid-diff-patching.md` +- Module dossier: `docs/modules/binary-index/hybrid-diff-stack.md` +- Sprint plan: `docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md` + +## De-duplication note + +This advisory extends earlier binary diff and symbol mapping advisory work, not +replace it: + +- `docs-archived/product/advisories/30-Dec-2025 - Binary Diff Signatures for Patch Detection.md` +- `docs-archived/product/advisories/18-Dec-2025 - Building Better Binary Mapping and Call-Stack Reachability.md` diff --git a/docs-archived/product/advisories/16-Feb-2026 - eBPF micro-witness deterministic replay across distros.md b/docs-archived/product/advisories/16-Feb-2026 - eBPF micro-witness deterministic replay across distros.md new file mode 100644 index 000000000..2779eefd9 --- /dev/null +++ b/docs-archived/product/advisories/16-Feb-2026 - eBPF micro-witness deterministic replay across distros.md @@ -0,0 +1,24 @@ +# 16-Feb-2026 - eBPF micro-witness deterministic replay across distros + +## Advisory source +- Source: user-provided product advisory text (review session, 2026-02-16 UTC). +- Scope: CO-RE eBPF micro-witnesses replayable and deterministic across kernels, distros, and toolchains, with DSSE + Sigstore bundle portability. + +## Outcome +- Result: partially aligned implementation with confirmed contract and implementation gaps. +- Decision: advisory translated into product/module docs plus an active implementation sprint. + +## Confirmed gap themes +- Runtime collector support check is hard-gated on `/sys/kernel/btf/vmlinux`; split-BTF/external-vmlinux fallback behavior is not implemented as a deterministic recorded contract. +- Runtime witness payload lacks required deterministic symbolization tuple for cross-distro replay (`symbolizer`, `libc_variant`, `sysroot`, debug/symbol pointers). +- Runtime witness generation pipeline is interface-defined but not implemented end-to-end in Scanner. +- DSSE witness support exists, but per-witness Sigstore bundle contract (`trace.sigstore.json`) is not standardized in witness storage/export/indexing. + +## Translation artifacts +- Active sprint: `docs/implplan/SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile.md` +- Product update: `docs/product/ebpf-micro-witness-determinism.md` +- Module contract: `docs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.md` + +## Notes +- External web fetches: none. +- Repository verification inputs included runtime and storage code paths under `src/Signals/`, `src/Scanner/`, `src/RuntimeInstrumentation/`, `src/Attestor/`, and `src/EvidenceLocker/`. diff --git a/docs/README.md b/docs/README.md index d9a1acd16..d2f3d0418 100755 --- a/docs/README.md +++ b/docs/README.md @@ -119,6 +119,7 @@ This documentation set is intentionally consolidated and does not maintain compa - Security deep dives: `docs/security/` - Benchmarks and fixtures: `docs/benchmarks/`, `docs/assets/` - Product advisories: `docs/product/advisories/` +- Hybrid diff patching blueprint: `docs/hybrid-diff-patching.md` --- @@ -140,3 +141,4 @@ This documentation set is intentionally consolidated and does not maintain compa - **Evidence-linked decisions:** every decision links to concrete evidence artifacts. - **Digest-first identity:** releases are immutable OCI digests, not mutable tags. - **Pluggable integrations:** connectors and steps are extensible; the core evidence chain stays stable. + diff --git a/docs/hybrid-diff-patching.md b/docs/hybrid-diff-patching.md new file mode 100644 index 000000000..8efef1b7c --- /dev/null +++ b/docs/hybrid-diff-patching.md @@ -0,0 +1,45 @@ +# Hybrid Diff Patching (Source + Symbols + Binary) + +## Purpose + +This document captures the product-level blueprint for hybrid diff patching: + +- Source semantic edits (AST-level intent). +- Build-time symbol mapping (source ranges to binary symbols and addresses). +- Normalized binary deltas (stable and compact byte patches). +- Signed evidence bundle for policy gating and replay. + +The goal is to make release decisions auditable at function granularity while +remaining deterministic and offline-capable. + +## Review outcome (2026-02-16) + +The advisory is directionally aligned with existing Stella Ops work but not +fully implemented end-to-end. + +Already present: + +- ELF normalization and delta hashing pipeline in BinaryIndex. +- DeltaSig attestation models and CLI flows for extract/author/sign/verify. +- Symbol manifest model with debug/code identifiers and source path metadata. + +Missing or incomplete for the full hybrid stack: + +- AST semantic edit-script generation and stable source anchors. +- Build artifact contract that emits canonical `symbol_map.json` from DWARF/PDB + during build. +- Deterministic source-edit -> symbol patch plan artifact. +- Verifier workflow that reconciles AST anchors with symbol boundaries and + normalized per-symbol deltas in one attested contract. + +## Canonical module dossier + +Detailed contracts, phased implementation, and policy hooks are defined in: + +- `docs/modules/binary-index/hybrid-diff-stack.md` + +## Execution sprint + +Implementation planning for this advisory is tracked in: + +- `docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md` diff --git a/docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md b/docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md new file mode 100644 index 000000000..887bf1b62 --- /dev/null +++ b/docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md @@ -0,0 +1,126 @@ +# Sprint 20260216-001 - Hybrid Diff Patch Pipeline + +## Topic & Scope +- Translate advisory guidance into an executable cross-module delivery plan for source-to-binary patch evidence. +- Define deterministic contracts for semantic edit scripts, symbol maps, symbol patch plans, and normalized per-symbol deltas. +- Wire policy and verification expectations so Release Orchestrator can gate on function-level change intent and byte-level proof. +- Working directory: `src/BinaryIndex/`. +- Expected evidence: targeted unit/integration tests, deterministic fixture artifacts, DSSE predicate samples, updated module docs. + +## Dependencies & Concurrency +- Depends on existing DeltaSig v2 predicate baseline in `src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/`. +- Safe parallel workstreams: + - source semantic edit artifact generation (`src/Tools/` or `src/ReleaseOrchestrator/` integration) + - symbol map extraction contracts (`src/Symbols/`) + - normalized delta and verifier integration (`src/BinaryIndex/`, `src/Attestor/`, `src/Doctor/`) +- Cross-module edits are explicitly allowed for this sprint in: + - `src/Symbols/` + - `src/EvidenceLocker/` + - `src/Policy/` + - `src/ReleaseOrchestrator/` + - `src/Attestor/` + - `src/Doctor/` + - `src/Web/` + - `docs/modules/**` + +## Documentation Prerequisites +- `docs/hybrid-diff-patching.md` +- `docs/modules/binary-index/hybrid-diff-stack.md` +- `docs/modules/binary-index/semantic-diffing.md` +- `docs/modules/binary-index/deltasig-v2-schema.md` +- `docs/modules/evidence-locker/guides/evidence-pack-schema.md` + +## Delivery Tracker + +### BHP-01 - Source semantic edit script artifact +Status: TODO +Dependency: none +Owners: Developer, Documentation author +Task description: +- Add deterministic source semantic edit artifact generation that emits stable + node identifiers and symbol anchors for changed code elements. +- Integrate artifact emission into release comparison flow and persist into + evidence pipelines. + +Completion criteria: +- [ ] A `semantic_edit_script.json` contract is implemented and validated with tests. +- [ ] Artifact generation is deterministic across repeated runs with identical inputs. +- [ ] Documentation for schema and limits is added to module dossier docs. + +### BHP-02 - Build symbol map contract and build-id binding +Status: TODO +Dependency: BHP-01 +Owners: Developer +Task description: +- Emit canonical `symbol_map.json` with source ranges, symbol boundaries, and + build-id metadata from DWARF/PDB capable pipelines. +- Ensure map digests and build-id values are linked into DeltaSig/attestation + subjects for replay validation. + +Completion criteria: +- [ ] Symbol map generation is implemented for supported binary formats in scope. +- [ ] Build-id and map digest are bound in emitted attestation payloads. +- [ ] Tests cover mapping correctness and deterministic ordering. + +### BHP-03 - Symbol patch plan and normalized per-symbol delta manifests +Status: TODO +Dependency: BHP-02 +Owners: Developer +Task description: +- Join semantic edits and symbol maps into `symbol_patch_plan.json` and + generate normalized per-symbol deltas and `patch_manifest.json` outputs. +- Remove placeholder function address/size derivation in DeltaSig generation + where exact boundaries are required for audit claims. + +Completion criteria: +- [ ] Symbol patch plan artifact exists and links to AST anchors and symbol ids. +- [ ] Patch manifest includes pre/post hashes, address ranges, and delta digests. +- [ ] DeltaSig function-level outputs use real boundaries and sizes in covered paths. + +### BHP-04 - Verifier and attestation enforcement +Status: TODO +Dependency: BHP-03 +Owners: Developer, Test Automation +Task description: +- Add verifier flow for build-id matching, re-normalization checks, dry-run delta + application, and boundary/hash reconciliation. +- Extend attestation validation logic in Attestor/Doctor and produce actionable + verification evidence for release decisions. + +Completion criteria: +- [ ] Verifier checks fail closed on build-id mismatch, boundary mismatch, or hash mismatch. +- [ ] DSSE validation and replay checks are captured in test evidence. +- [ ] CLI/API surfaces expose verification outcome details for operators. + +### BHP-05 - Policy and Evidence Locker integration +Status: TODO +Dependency: BHP-04 +Owners: Developer, Product Manager +Task description: +- Add policy gate inputs for symbol-count change budgets, namespace restrictions, + API-surface invariants, and byte budget thresholds. +- Store hybrid diff artifacts in Evidence Locker and expose summary/read paths in + UI and release records. + +Completion criteria: +- [ ] Policy rules can gate promotions using hybrid diff metrics. +- [ ] Evidence Locker stores and retrieves the full hybrid artifact chain. +- [ ] UI/CLI render concise "what changed" summaries with links to signed evidence. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-02-16 | Sprint created from product advisory review for hybrid source-symbol-binary diff pipeline. | Product Manager | + +## Decisions & Risks +- Advisory overlap confirmed with archived advisories: + - `docs-archived/product/advisories/30-Dec-2025 - Binary Diff Signatures for Patch Detection.md` + - `docs-archived/product/advisories/18-Dec-2025 - Building Better Binary Mapping and Call-Stack Reachability.md` +- Decision: treat this advisory as an extension that unifies source intent and binary proof in one contract chain, not as a duplicate effort. +- Risk: multi-module coordination can drift schemas; mitigation is to keep canonical contracts in BinaryIndex dossier and require digest-linked schema versions in attestations. +- Risk: AST differencing backend choice may vary by language; mitigation is a language-agnostic output schema with adapter-specific provenance fields. + +## Next Checkpoints +- 2026-02-18: Contract freeze review for artifact schemas (`semantic_edit_script`, `symbol_map`, `symbol_patch_plan`, `patch_manifest`). +- 2026-02-22: First end-to-end dry run in CI with signed evidence and verifier replay. +- 2026-02-26: Policy gate integration demo with allow/deny examples on symbol namespaces. diff --git a/docs/implplan/SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile.md b/docs/implplan/SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile.md new file mode 100644 index 000000000..556673617 --- /dev/null +++ b/docs/implplan/SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile.md @@ -0,0 +1,102 @@ +# Sprint SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile - eBPF Micro-Witness Determinism + +## Topic & Scope +- Translate the eBPF micro-witness advisory into implementation-ready contracts and sprint tasks. +- Close determinism gaps for runtime witness replay across kernel/distro/toolchain variance. +- Define one portable evidence profile for DSSE + Sigstore bundle based offline replay. +- Working directory: `docs/`. +- Cross-module edits explicitly allowed for implementation tasks: `src/Signals/`, `src/Scanner/`, `src/Attestor/`, `src/EvidenceLocker/`. +- Expected evidence: contract docs, schema/API updates, targeted module tests, offline verification artifacts. + +## Dependencies & Concurrency +- Upstream contracts: `docs/contracts/witness-v1.md`, `docs/modules/attestor/repro-bundle-profile.md`, `docs/modules/evidence/unified-model.md`. +- Safe parallelism: +- Signals loader/BTF work can run in parallel with Attestor/Evidence Locker bundle contract work. +- Scanner witness model updates should run after profile fields are frozen. + +## Documentation Prerequisites +- `docs/product/ebpf-micro-witness-determinism.md` +- `docs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.md` +- `docs/reachability/deployment-guide.md` +- `docs/contracts/witness-v1.md` + +## Delivery Tracker + +### MWD-001 - Signals BTF fallback contract and metadata emission +Status: TODO +Dependency: none +Owners: Product Manager, Developer +Task description: +- Implement deterministic BTF selection order in the runtime collector and emit selected source metadata (`source_kind`, `source_path`, `source_digest`, `selection_reason`) into runtime evidence/witness context. +- Ensure behavior is explicit for kernel BTF, external vmlinux BTF, and split-BTF fallback. + +Completion criteria: +- [ ] Collector no longer fails solely on missing `/sys/kernel/btf/vmlinux` when configured fallback BTF exists. +- [ ] Runtime evidence includes immutable BTF selection metadata required for replay. + +### MWD-002 - Runtime witness schema extensions for deterministic symbolization +Status: TODO +Dependency: MWD-001 +Owners: Developer, Documentation author +Task description: +- Extend runtime witness payload schema to include deterministic symbolization tuple: `build_id`, debug/symbol pointer(s), symbolizer identity/version/digest, libc variant, and sysroot digest. +- Update witness contracts and validation rules in docs and implementation. + +Completion criteria: +- [ ] Witness schema and code models carry required symbolization fields. +- [ ] Validation rejects witnesses missing required deterministic symbolization inputs. + +### MWD-003 - Implement Scanner runtime witness generation pipeline +Status: TODO +Dependency: MWD-002 +Owners: Developer, Test Automation +Task description: +- Deliver concrete `IRuntimeWitnessGenerator` implementation, integrating runtime observations, witness building, DSSE signing, and storage. +- Ensure deterministic ordering/canonicalization for runtime observation payloads. + +Completion criteria: +- [ ] Runtime witness generation is implemented (not interface-only) and wired into runtime instrumentation flow. +- [ ] Determinism tests show stable witness bytes for fixed inputs. + +### MWD-004 - DSSE plus Sigstore bundle witness packaging +Status: TODO +Dependency: MWD-003 +Owners: Developer, Documentation author +Task description: +- Standardize and implement per-witness artifact triplet: `trace.json`, `trace.dsse.json`, `trace.sigstore.json`. +- Store and export this profile through Evidence Locker with offline verification compatibility. + +Completion criteria: +- [ ] Evidence Locker manifest/index model supports the Sigstore bundle artifact and links it to witness identity. +- [ ] Offline verify workflow succeeds using bundle-contained material only. + +### MWD-005 - Cross-distro deterministic replay test matrix +Status: TODO +Dependency: MWD-004 +Owners: Test Automation, QA +Task description: +- Add targeted replay verification across kernel/libc matrix (minimum 3 kernels, glibc + musl), asserting byte-identical replay frames for fixed witness artifacts. +- Capture command output and evidence artifacts for deterministic QA sign-off. + +Completion criteria: +- [ ] Matrix tests run against targeted projects (not solution filters) and show deterministic replay output. +- [ ] Execution evidence is recorded with artifact hashes and replay verification logs. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-02-16 | Sprint created from eBPF micro-witness advisory review; gaps confirmed and translated to implementation tasks. | Project Manager | + +## Decisions & Risks +- Decision: Adopt a single micro-witness determinism profile defined in `docs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.md`. +- Decision: Product-level promise and current baseline are captured in `docs/product/ebpf-micro-witness-determinism.md`. +- Decision: Advisory translation record archived at `docs-archived/product/advisories/16-Feb-2026 - eBPF micro-witness deterministic replay across distros.md`. +- Risk: Existing runtime collector hard dependency on kernel BTF may block non-BTF kernels until fallback path is implemented. +- Risk: Runtime witness generation remains incomplete without a concrete generator implementation; downstream attestation/export is blocked. +- Risk: Absence of standardized Sigstore witness bundle may produce non-portable replay evidence across environments. +- External web fetches: none. + +## Next Checkpoints +- 2026-02-18: Contract review sign-off (Signals/Scanner/Attestor/Evidence Locker owners). +- 2026-02-21: MWD-001 and MWD-002 implementation readiness checkpoint. +- 2026-02-25: First end-to-end deterministic replay demo with DSSE + Sigstore witness bundle. diff --git a/docs/modules/binary-index/README.md b/docs/modules/binary-index/README.md index 320e84b05..13546d1b1 100644 --- a/docs/modules/binary-index/README.md +++ b/docs/modules/binary-index/README.md @@ -37,6 +37,7 @@ Key features: ## Related Documentation - Architecture: `./architecture.md` +- Hybrid Diff Stack: `./hybrid-diff-stack.md` - High-Level Architecture: `../../ARCHITECTURE_OVERVIEW.md` - Scanner Architecture: `../scanner/architecture.md` - Concelier Architecture: `../concelier/architecture.md` @@ -63,7 +64,7 @@ A major enhancement to BinaryIndex is planned to enable **semantic-level binary | **Phase 1** | IR-Level Semantic Analysis | +15% accuracy on optimized binaries | Planned | | **Phase 2** | Function Behavior Corpus | +10% coverage on stripped binaries | Planned | | **Phase 3** | Ghidra Integration | +5% edge case handling | Planned | -| **Phase 4** | Decompiler & ML Similarity | +10% obfuscation resilience | Planned | +| **Phase 4** | Decompiler and ML Similarity | +10% obfuscation resilience | Planned | ### New Libraries (Planned) diff --git a/docs/modules/binary-index/architecture.md b/docs/modules/binary-index/architecture.md index e01992139..d7ec483ec 100644 --- a/docs/modules/binary-index/architecture.md +++ b/docs/modules/binary-index/architecture.md @@ -3,7 +3,7 @@ > **Ownership:** Scanner Guild + Concelier Guild > **Status:** DRAFT > **Version:** 1.0.0 -> **Related:** [High-Level Architecture](../../ARCHITECTURE_OVERVIEW.md), [Scanner Architecture](../scanner/architecture.md), [Concelier Architecture](../concelier/architecture.md) +> **Related:** [High-Level Architecture](../../ARCHITECTURE_OVERVIEW.md), [Scanner Architecture](../scanner/architecture.md), [Concelier Architecture](../concelier/architecture.md), [Hybrid Diff Stack](./hybrid-diff-stack.md) --- @@ -1774,3 +1774,4 @@ inside `AddNormalizationPipelines()` in `ServiceCollectionExtensions.cs`. *Document Version: 1.5.0* *Last Updated: 2026-02-12* + diff --git a/docs/modules/binary-index/hybrid-diff-stack.md b/docs/modules/binary-index/hybrid-diff-stack.md new file mode 100644 index 000000000..cd52779f0 --- /dev/null +++ b/docs/modules/binary-index/hybrid-diff-stack.md @@ -0,0 +1,163 @@ +# Hybrid Diff Stack Architecture (Source -> Symbols -> Normalized Bytes) + +> Status: Planned (advisory translation, 2026-02-16) +> Module: BinaryIndex with cross-module contracts (Symbols, EvidenceLocker, Policy, Attestor, ReleaseOrchestrator) + +## 1. Objective + +Produce compact, auditable patch artifacts that preserve developer intent and +binary truth at the same time: + +- Source-level intent: semantic edit scripts anchored to classes/functions. +- Build-level mapping: symbol map linked to immutable build identity. +- Binary-level patching: normalization-first per-symbol deltas. +- Release evidence: DSSE-signed contract consumed by policy and replay. + +## 2. Current implementation baseline + +Implemented today: + +- ELF normalization passes and deterministic delta hash generation. +- DeltaSig predicate contracts (v1 and v2) with CLI author/sign/verify flows. +- Symbol manifest model with debug id, code id, source paths, and line data. + +Gaps for full advisory scope: + +- No AST semantic edit script artifact pipeline in current release workflow. +- No canonical builder output for source-range to symbol-address map as a + first-class build artifact contract. +- No end-to-end "source edits -> symbol patch plan -> normalized deltas" + bundle schema consumed by release policy. +- Existing function delta composition still contains placeholder address/size + behavior in parts of DeltaSig generation. + +## 3. Target contracts + +### 3.1 Source semantic edit script (`semantic_edit_script.json`) + +Required fields: + +- `schemaVersion` +- `sourceTreeDigest` +- `edits[]` where each edit includes: + - `editType`: `add|remove|move|update|rename` + - `nodeKind`: `class|method|field|import|statement` + - `nodePath`: stable language-specific path + - `anchor`: symbol-like identifier (for example `Namespace.Type.Method`) + - `pre` and `post` source spans and digests + +Determinism rules: + +- Stable sort by file path, then node path. +- Stable source digests and normalized paths. + +### 3.2 Symbol map (`symbol_map.json`) + +Produced during build from DWARF/PDB + build metadata. + +Required fields: + +- `schemaVersion` +- `buildId` +- `binaryDigest` +- `symbols[]`: + - `name` + - `kind` (`function|object|section`) + - `addressStart` and `addressEnd` + - `section` + - `sourceRanges[]` (`file`, `lineStart`, `lineEnd`) + +Determinism rules: + +- Symbol ordering by address then name. +- Build id must match attestation subject. + +### 3.3 Symbol patch plan (`symbol_patch_plan.json`) + +Joins source edits with concrete symbols. + +Required fields: + +- `schemaVersion` +- `buildIdBefore` and `buildIdAfter` +- `editsDigest` +- `symbolMapDigestBefore` and `symbolMapDigestAfter` +- `changes[]`: + - `symbol` + - `changeType` (`added|removed|modified|moved`) + - `astAnchors[]` + - `preHash` and `postHash` + - `deltaRef` + +### 3.4 Patch manifest (`patch_manifest.json`) + +Binds per-symbol normalized deltas to evidence and policy. + +Required fields: + +- `schemaVersion` +- `buildId` +- `normalizationRecipeId` +- `patches[]`: + - `symbol` + - `addressRange` + - `deltaDigest` + - `pre` (`size`, `hash`) + - `post` (`size`, `hash`) +- `attestation` (`predicateType`, `dsseDigest`) + +## 4. Evidence and policy integration + +EvidenceLocker stores four linked artifacts per release comparison: + +1. semantic edit script +2. symbol maps (before/after) +3. symbol patch plan +4. normalized patch manifest + delta blobs + +Policy hooks: + +- Allowlist/denylist by namespace or symbol path. +- Max function-count and max byte budget controls. +- API surface change checks. +- Hot-path and cryptography namespace protection rules. + +## 5. Verifier contract (Attestor/Doctor) + +Verifier must prove all of the following before promotion: + +- Build-id and subject digest alignment. +- Re-normalization of target binary with matching recipe id. +- Dry-run delta application succeeds within declared symbol boundaries. +- Resulting hashes equal manifest `post` values. +- AST anchors reconcile to changed symbols in symbol patch plan. +- DSSE signatures and transparency references validate per policy. + +## 6. Integration boundaries + +Builder step (CI): emit symbol map and normalized segments. + +ReleaseOrchestrator step: combine source edits, symbol maps, and normalized +bytes into patch plan and manifest. + +BinaryIndex/DeltaSig: own normalization and per-symbol diff generation. + +Attestor/Doctor: own verification and attestation checks. + +EvidenceLocker: own storage schema and query surfaces. + +Policy: consume summarized patch-plan metrics and rule evaluations. + +## 7. Implementation tracker + +Execution is tracked in: + +- `docs/implplan/SPRINT_20260216_001_BinaryIndex_hybrid_diff_patch_pipeline.md` + +## 8. Related documents + +- `docs/hybrid-diff-patching.md` +- `docs/modules/binary-index/semantic-diffing.md` +- `docs/modules/binary-index/deltasig-v2-schema.md` +- `docs/modules/scanner/binary-diff-attestation.md` +- `docs/modules/evidence-locker/guides/evidence-pack-schema.md` diff --git a/docs/modules/signals/README.md b/docs/modules/signals/README.md index 19b145b5b..407208fd8 100644 --- a/docs/modules/signals/README.md +++ b/docs/modules/signals/README.md @@ -49,6 +49,7 @@ Key settings: ## Related Documentation - Architecture: `./architecture.md` +- Contract: `./contracts/ebpf-micro-witness-determinism-profile.md` - Policy Engine: `../policy/` - VexLens: `../vex-lens/` - High-Level Architecture: `../../ARCHITECTURE_OVERVIEW.md` diff --git a/docs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.md b/docs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.md new file mode 100644 index 000000000..392975967 --- /dev/null +++ b/docs/modules/signals/contracts/ebpf-micro-witness-determinism-profile.md @@ -0,0 +1,124 @@ +# eBPF Micro-Witness Determinism Profile v1.0.0 + +**Status:** PLANNED +**Version:** 1.0.0 +**Effective:** 2026-02-16 +**Owner:** Signals Guild + Scanner Guild + Attestor Guild + Evidence Locker Guild +**Sprint:** `docs/implplan/SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile.md` + +--- + +## 1. Purpose + +This profile defines the minimum deterministic contract for runtime eBPF "micro-witnesses" so replay yields the same symbolized result across distros/toolchains and in offline environments. + +--- + +## 2. Contract Scope + +- Runtime collection and BTF selection (`Signals`). +- Runtime witness payload schema and signing (`Scanner`). +- DSSE and transparency evidence shape (`Attestor`). +- Portable storage/export/indexing (`Evidence Locker`). + +--- + +## 3. Runtime Loader Contract (BTF Selection) + +### 3.1 Selection order (mandatory) +1. `/sys/kernel/btf/vmlinux` +2. configured full-kernel BTF path (for example distro debug package path) +3. split-BTF selected by `{kernel_release, arch}` + +### 3.2 Required emitted metadata + +```json +{ + "kernel_release": "6.8.0-45-generic", + "kernel_arch": "x86_64", + "btf": { + "source_kind": "kernel|external-vmlinux|split-btf", + "source_path": "/sys/kernel/btf/vmlinux", + "source_digest": "sha256:...", + "selection_reason": "kernel_btf_present" + } +} +``` + +`source_path` and `source_digest` are mandatory for deterministic replay. + +--- + +## 4. Deterministic Symbolization Contract + +Each runtime witness must carry deterministic symbolization inputs: + +```json +{ + "symbolization": { + "build_id": "gnu-build-id:...", + "debug_artifact_uri": "cas://symbols/by-build-id/gnu-build-id:.../artifact.debug", + "symbol_table_uri": "cas://symbols/by-build-id/gnu-build-id:.../symtab.json", + "symbolizer": { + "name": "llvm-symbolizer", + "version": "18.1.7", + "digest": "sha256:..." + }, + "libc_variant": "glibc|musl", + "sysroot_digest": "sha256:..." + } +} +``` + +At least one of `debug_artifact_uri` or `symbol_table_uri` must be present. + +--- + +## 5. Witness Packaging Contract + +Each micro-witness must be exportable as: + +1. `trace.json` (canonical payload) +2. `trace.dsse.json` (DSSE envelope) +3. `trace.sigstore.json` (Sigstore bundle with signature/cert/transparency proof) + +Offline verification must use only bundle-contained material (no network dependency). + +--- + +## 6. Evidence Locker Index Contract + +Evidence Locker must index runtime witness artifacts by: + +- `build_id` +- `kernel_release` +- `probe_id` +- `policy_run_id` + +These keys are required for deterministic replay lookup and audit search. + +--- + +## 7. Validation Matrix (minimum) + +- Kernel matrix: at least 3 supported kernel lines. +- libc matrix: glibc + musl. +- Verification modes: online + offline. +- Determinism check: byte-identical replayed frame output for fixed input evidence. + +--- + +## 8. Confirmed Gaps (2026-02-16 Baseline) + +- Hard BTF dependency with no split-BTF fallback metadata contract in collector: + - `src/Signals/__Libraries/StellaOps.Signals.Ebpf/Services/RuntimeSignalCollector.cs` +- Probe load path is simulated and does not record selected BTF source: + - `src/Signals/__Libraries/StellaOps.Signals.Ebpf/Probes/CoreProbeLoader.cs` +- Runtime witness payload lacks required symbolization tuple fields: + - `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitness.cs` + - `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/RuntimeObservation.cs` +- Runtime witness generator implementation is missing: + - `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/IRuntimeWitnessGenerator.cs` +- Sigstore bundle (`trace.sigstore.json`) is not yet standardized in witness storage/export: + - `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/013_witness_storage.sql` + - `src/EvidenceLocker/__Libraries/StellaOps.EvidenceLocker.Export/Models/BundleManifest.cs` diff --git a/docs/product/README.md b/docs/product/README.md index a434f208a..253dcc493 100644 --- a/docs/product/README.md +++ b/docs/product/README.md @@ -12,6 +12,7 @@ Product strategy, competitive analysis, and marketing bridge documents. | [decision-capsules.md](decision-capsules.md) | Decision Capsules concept (audit-grade evidence bundles) | | [evidence-linked-vex.md](evidence-linked-vex.md) | Evidence-linked VEX technical bridge | | [hybrid-reachability.md](hybrid-reachability.md) | Hybrid reachability feature positioning | +| [ebpf-micro-witness-determinism.md](ebpf-micro-witness-determinism.md) | eBPF micro-witness deterministic replay profile and current implementation gaps | | [portable-audit-pack-plan.md](portable-audit-pack-plan.md) | Portable supply-chain audit pack rollout plan | | [reachability-benchmark-launch.md](reachability-benchmark-launch.md) | Reachability benchmark launch materials | diff --git a/docs/product/ebpf-micro-witness-determinism.md b/docs/product/ebpf-micro-witness-determinism.md new file mode 100644 index 000000000..149b8c6ea --- /dev/null +++ b/docs/product/ebpf-micro-witness-determinism.md @@ -0,0 +1,36 @@ +# eBPF Micro-Witness Determinism Profile + +## Status +- Advisory translated: 2026-02-16 (UTC) +- Current implementation status: gaps confirmed +- Implementation sprint: `docs/implplan/SPRINT_20260216_001_Signals_ebpf_micro_witness_determinism_profile.md` + +## Purpose +- Define what "replayable and deterministic micro-witnesses" means for Stella Ops runtime evidence. +- Align Signals, Scanner, Attestor, and Evidence Locker on one verifiable output profile. +- Ensure the same incident replay result across distros/toolchains and in offline mode. + +## Required product behavior +1. One CO-RE probe object must run unchanged across supported kernels when BTF is available. +2. If kernel BTF is missing, the loader must use deterministic fallback selection and record exactly what BTF source was used. +3. Runtime witnesses must include deterministic symbolization inputs (build identity + symbol/debug material + toolchain tuple). +4. Witness evidence must be portable as DSSE plus a Sigstore bundle that can be verified offline. + +## Verified current state (2026-02-16) +- eBPF support check currently hard-requires `/sys/kernel/btf/vmlinux` with no split-BTF fallback path selection metadata in collector output. + - `src/Signals/__Libraries/StellaOps.Signals.Ebpf/Services/RuntimeSignalCollector.cs` +- Probe loader path is simulated for runtime attachment lifecycle and does not implement deterministic BTF source recording. + - `src/Signals/__Libraries/StellaOps.Signals.Ebpf/Probes/CoreProbeLoader.cs` +- Runtime witness model includes `build_id` but does not include symbol bundle pointers or symbolizer/libc/sysroot tuple required for cross-distro deterministic symbolization. + - `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitness.cs` + - `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/RuntimeObservation.cs` +- Runtime witness generator is interface-defined but has no production implementation in Scanner. + - `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/IRuntimeWitnessGenerator.cs` +- DSSE envelope support exists; end-to-end per-witness Sigstore bundle contract (`trace.sigstore.json`) is not standardized in witness storage/indexing. + - `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/WitnessDsseSigner.cs` + - `src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/013_witness_storage.sql` + - `src/EvidenceLocker/__Libraries/StellaOps.EvidenceLocker.Export/Models/BundleManifest.cs` + +## Decision +- Advisory is accepted as implementation-required. +- Contract and sprint tasks are created to close deterministic replay gaps. diff --git a/src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/HybridDiffContracts.cs b/src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/HybridDiffContracts.cs new file mode 100644 index 000000000..5961e4d5a --- /dev/null +++ b/src/BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/HybridDiffContracts.cs @@ -0,0 +1,435 @@ +// Copyright (c) StellaOps. All rights reserved. +// Licensed under BUSL-1.1. See LICENSE in the project root. + +using System.Text.Json.Serialization; + +namespace StellaOps.BinaryIndex.DeltaSig; + +/// +/// Source file pair used to compute semantic edit scripts. +/// +public sealed record SourceFileDiff +{ + /// + /// Source file path (repository-relative). + /// + public required string Path { get; init; } + + /// + /// Previous file content. + /// + public string? BeforeContent { get; init; } + + /// + /// Current file content. + /// + public string? AfterContent { get; init; } +} + +/// +/// Source line span. +/// +public sealed record SourceSpan +{ + /// + /// 1-based start line. + /// + public required int StartLine { get; init; } + + /// + /// 1-based end line. + /// + public required int EndLine { get; init; } +} + +/// +/// Deterministic semantic edit script. +/// +public sealed record SemanticEditScript +{ + /// + /// Schema version. + /// + public string SchemaVersion { get; init; } = "1.0.0"; + + /// + /// Digest of the full source tree diff input. + /// + public required string SourceTreeDigest { get; init; } + + /// + /// Deterministic ordered edits. + /// + public required IReadOnlyList Edits { get; init; } +} + +/// +/// Single semantic edit entry. +/// +public sealed record SemanticEdit +{ + /// + /// Stable digest-derived ID for dedupe and audit references. + /// + public required string StableId { get; init; } + + /// + /// Edit type: add, remove, move, update, rename. + /// + public required string EditType { get; init; } + + /// + /// Node kind: file, method, class, field, import, statement. + /// + public required string NodeKind { get; init; } + + /// + /// Deterministic node path. + /// + public required string NodePath { get; init; } + + /// + /// Symbol anchor used to link to binary symbols. + /// + public required string Anchor { get; init; } + + /// + /// Pre-change source span. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SourceSpan? PreSpan { get; init; } + + /// + /// Post-change source span. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SourceSpan? PostSpan { get; init; } + + /// + /// Pre-change digest. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PreDigest { get; init; } + + /// + /// Post-change digest. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PostDigest { get; init; } +} + +/// +/// Build-stage symbol map linked to build identity. +/// +public sealed record SymbolMap +{ + /// + /// Schema version. + /// + public string SchemaVersion { get; init; } = "1.0.0"; + + /// + /// Build ID (ELF build-id, PDB GUID, or equivalent). + /// + public required string BuildId { get; init; } + + /// + /// Optional binary digest for traceability. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? BinaryDigest { get; init; } + + /// + /// Address derivation source (manifest, synthetic-signature). + /// + public string AddressSource { get; init; } = "manifest"; + + /// + /// Ordered symbol entries. + /// + public required IReadOnlyList Symbols { get; init; } +} + +/// +/// Symbol map entry. +/// +public sealed record SymbolMapEntry +{ + /// + /// Symbol name. + /// + public required string Name { get; init; } + + /// + /// Symbol kind: function, object, section. + /// + public string Kind { get; init; } = "function"; + + /// + /// Start address. + /// + public required ulong AddressStart { get; init; } + + /// + /// End address. + /// + public required ulong AddressEnd { get; init; } + + /// + /// Containing section. + /// + public string Section { get; init; } = ".text"; + + /// + /// Source ranges for this symbol. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? SourceRanges { get; init; } + + /// + /// Symbol size derived from address range. + /// + [JsonIgnore] + public long Size => checked((long)(AddressEnd >= AddressStart ? AddressEnd - AddressStart + 1UL : 0UL)); +} + +/// +/// Source range metadata. +/// +public sealed record SourceRange +{ + /// + /// Source file path. + /// + public required string File { get; init; } + + /// + /// 1-based start line. + /// + public required int LineStart { get; init; } + + /// + /// 1-based end line. + /// + public required int LineEnd { get; init; } +} + +/// +/// Join artifact linking semantic edits to symbol changes. +/// +public sealed record SymbolPatchPlan +{ + /// + /// Schema version. + /// + public string SchemaVersion { get; init; } = "1.0.0"; + + /// + /// Build ID before patch. + /// + public required string BuildIdBefore { get; init; } + + /// + /// Build ID after patch. + /// + public required string BuildIdAfter { get; init; } + + /// + /// Semantic script digest. + /// + public required string EditsDigest { get; init; } + + /// + /// Old symbol map digest. + /// + public required string SymbolMapDigestBefore { get; init; } + + /// + /// New symbol map digest. + /// + public required string SymbolMapDigestAfter { get; init; } + + /// + /// Ordered symbol changes. + /// + public required IReadOnlyList Changes { get; init; } +} + +/// +/// Single symbol patch plan entry. +/// +public sealed record SymbolPatchChange +{ + /// + /// Symbol name. + /// + public required string Symbol { get; init; } + + /// + /// Change type: added, removed, modified, moved. + /// + public required string ChangeType { get; init; } + + /// + /// Linked source anchors. + /// + public required IReadOnlyList AstAnchors { get; init; } + + /// + /// Hash before. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PreHash { get; init; } + + /// + /// Hash after. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PostHash { get; init; } + + /// + /// Delta reference digest. + /// + public required string DeltaRef { get; init; } +} + +/// +/// Normalized patch manifest for per-symbol deltas. +/// +public sealed record PatchManifest +{ + /// + /// Schema version. + /// + public string SchemaVersion { get; init; } = "1.0.0"; + + /// + /// Build ID. + /// + public required string BuildId { get; init; } + + /// + /// Normalization recipe identifier. + /// + public required string NormalizationRecipeId { get; init; } + + /// + /// Ordered patch entries. + /// + public required IReadOnlyList Patches { get; init; } + + /// + /// Total delta bytes across symbols. + /// + [JsonIgnore] + public long TotalDeltaBytes => Patches.Sum(p => p.DeltaSizeBytes); +} + +/// +/// Per-symbol patch artifact. +/// +public sealed record SymbolPatchArtifact +{ + /// + /// Symbol name. + /// + public required string Symbol { get; init; } + + /// + /// Address range in hex format. + /// + public required string AddressRange { get; init; } + + /// + /// Digest of patch payload. + /// + public required string DeltaDigest { get; init; } + + /// + /// Pre-patch metrics. + /// + public required PatchSizeHash Pre { get; init; } + + /// + /// Post-patch metrics. + /// + public required PatchSizeHash Post { get; init; } + + /// + /// Absolute byte delta. + /// + [JsonIgnore] + public long DeltaSizeBytes => Math.Abs(Post.Size - Pre.Size); +} + +/// +/// Size/hash tuple. +/// +public sealed record PatchSizeHash +{ + /// + /// Size in bytes. + /// + public required long Size { get; init; } + + /// + /// Hash digest. + /// + public required string Hash { get; init; } +} + +/// +/// Full hybrid diff evidence bundle. +/// +public sealed record HybridDiffEvidence +{ + /// + /// Semantic edit script. + /// + public required SemanticEditScript SemanticEditScript { get; init; } + + /// + /// Old symbol map. + /// + public required SymbolMap OldSymbolMap { get; init; } + + /// + /// New symbol map. + /// + public required SymbolMap NewSymbolMap { get; init; } + + /// + /// Symbol patch plan. + /// + public required SymbolPatchPlan SymbolPatchPlan { get; init; } + + /// + /// Normalized patch manifest. + /// + public required PatchManifest PatchManifest { get; init; } + + /// + /// Semantic edit script digest. + /// + public required string SemanticEditScriptDigest { get; init; } + + /// + /// Old symbol map digest. + /// + public required string OldSymbolMapDigest { get; init; } + + /// + /// New symbol map digest. + /// + public required string NewSymbolMapDigest { get; init; } + + /// + /// Symbol patch plan digest. + /// + public required string SymbolPatchPlanDigest { get; init; } + + /// + /// Patch manifest digest. + /// + public required string PatchManifestDigest { get; init; } +} diff --git a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs index 5c1610831..42bc7e868 100644 --- a/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs +++ b/src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs @@ -143,6 +143,13 @@ builder.Services.AddAuthorization(options => ? bootstrapOptions.Authority.RequiredScopes.ToArray() : new[] { StellaOpsScopes.VulnOperate }; + // Default policy uses StellaOpsScopeRequirement so bypass evaluator can grant + // access for requests from trusted networks (BypassNetworks) without a JWT. + options.DefaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme) + .AddRequirements(new StellaOpsScopeRequirement(scopes)) + .Build(); + options.AddPolicy(LedgerWritePolicy, policy => { policy.RequireAuthenticatedUser(); diff --git a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Extensions/NotifyPersistenceExtensions.cs b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Extensions/NotifyPersistenceExtensions.cs index 9dcc6dc95..6d5d154aa 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Persistence/Extensions/NotifyPersistenceExtensions.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Persistence/Extensions/NotifyPersistenceExtensions.cs @@ -23,7 +23,7 @@ public static class NotifyPersistenceExtensions IConfiguration configuration, string sectionName = "Postgres:Notify") { - services.Configure(sectionName, configuration.GetSection(sectionName)); + services.Configure(configuration.GetSection(sectionName)); services.AddSingleton(); // Register repositories diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PlatformAnalyticsQueryExecutor.cs b/src/Platform/StellaOps.Platform.WebService/Services/PlatformAnalyticsQueryExecutor.cs index 4a06b0b6c..52eb267fd 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PlatformAnalyticsQueryExecutor.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PlatformAnalyticsQueryExecutor.cs @@ -1,5 +1,6 @@ using Npgsql; +using NpgsqlTypes; using StellaOps.Platform.WebService.Contracts; using System; using System.Collections.Generic; @@ -85,7 +86,7 @@ public sealed class PlatformAnalyticsQueryExecutor : IPlatformAnalyticsQueryExec SUM(total_vulns) - SUM(vex_mitigated) AS net_exposure, SUM(kev_vulns) AS kev_vulns FROM analytics.daily_vulnerability_counts - WHERE snapshot_date >= CURRENT_DATE - (@days || ' days')::INTERVAL + WHERE snapshot_date >= CURRENT_DATE - make_interval(days => @days) AND (@environment IS NULL OR environment = @environment) GROUP BY snapshot_date, environment ORDER BY environment, snapshot_date; @@ -100,7 +101,8 @@ public sealed class PlatformAnalyticsQueryExecutor : IPlatformAnalyticsQueryExec await using var command = connection.CreateCommand(); command.CommandText = sql; command.Parameters.AddWithValue("days", days); - command.Parameters.AddWithValue("environment", (object?)environment ?? DBNull.Value); + var envParam = command.Parameters.Add("environment", NpgsqlDbType.Text); + envParam.Value = (object?)environment ?? DBNull.Value; var results = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); @@ -132,7 +134,7 @@ public sealed class PlatformAnalyticsQueryExecutor : IPlatformAnalyticsQueryExec SUM(total_components) AS total_components, SUM(unique_suppliers) AS unique_suppliers FROM analytics.daily_component_counts - WHERE snapshot_date >= CURRENT_DATE - (@days || ' days')::INTERVAL + WHERE snapshot_date >= CURRENT_DATE - make_interval(days => @days) AND (@environment IS NULL OR environment = @environment) GROUP BY snapshot_date, environment ORDER BY environment, snapshot_date; @@ -147,7 +149,8 @@ public sealed class PlatformAnalyticsQueryExecutor : IPlatformAnalyticsQueryExec await using var command = connection.CreateCommand(); command.CommandText = sql; command.Parameters.AddWithValue("days", days); - command.Parameters.AddWithValue("environment", (object?)environment ?? DBNull.Value); + var envParam2 = command.Parameters.Add("environment", NpgsqlDbType.Text); + envParam2.Value = (object?)environment ?? DBNull.Value; var results = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Policy/StellaOps.Policy.Engine/Program.cs b/src/Policy/StellaOps.Policy.Engine/Program.cs index 444d32ce6..a2448af22 100644 --- a/src/Policy/StellaOps.Policy.Engine/Program.cs +++ b/src/Policy/StellaOps.Policy.Engine/Program.cs @@ -291,6 +291,17 @@ builder.Services.AddStellaOpsResourceServerAuthentication( builder.Configuration, configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer"); +// Accept self-signed certificates when HTTPS metadata validation is disabled (dev/Docker) +if (!bootstrap.Options.ResourceServer.RequireHttpsMetadata) +{ + builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); +} + if (bootstrap.Options.Authority.Enabled) { builder.Services.AddStellaOpsAuthClient(clientOptions => diff --git a/src/Policy/StellaOps.Policy.Gateway/Program.cs b/src/Policy/StellaOps.Policy.Gateway/Program.cs index b8d963f82..3e6f51dc5 100644 --- a/src/Policy/StellaOps.Policy.Gateway/Program.cs +++ b/src/Policy/StellaOps.Policy.Gateway/Program.cs @@ -133,6 +133,9 @@ builder.Services.AddAuthentication(); builder.Services.AddAuthorization(); builder.Services.AddStellaOpsScopeHandler(); builder.Services.AddPolicyPostgresStorage(builder.Configuration); +// Also configure unnamed PostgresOptions so PolicyDataSource (IOptions) resolves the connection string. +builder.Services.Configure( + builder.Configuration.GetSection("Postgres:Policy")); builder.Services.AddMemoryCache(); // Exception services @@ -198,6 +201,20 @@ builder.Services.AddSingleton(); builder.Services.AddStellaOpsResourceServerAuthentication( builder.Configuration, configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer"); + +// Accept self-signed certificates when HTTPS metadata validation is disabled (dev/Docker) +if (!bootstrap.Options.ResourceServer.RequireHttpsMetadata) +{ + builder.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + }); +} + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs b/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs index 282fa65e5..fdaf01c56 100644 --- a/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs +++ b/src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs @@ -39,13 +39,20 @@ public sealed class IdentityHeaderPolicyMiddleware "X-Stella-Project", "X-Stella-Actor", "X-Stella-Scopes", + // Headers used by downstream services in header-based auth mode + "X-Scopes", + "X-Tenant-Id", // Raw claim headers (internal/legacy pass-through) "sub", "tid", "scope", "scp", "cnf", - "cnf.jkt" + "cnf.jkt", + // Auth headers consumed by the gateway — strip before proxying + // so backends trust identity headers instead of re-validating JWT. + "Authorization", + "DPoP" ]; public IdentityHeaderPolicyMiddleware( @@ -91,8 +98,18 @@ public sealed class IdentityHeaderPolicyMiddleware private void StripReservedHeaders(HttpContext context) { + var preserveAuthHeaders = _options.JwtPassthroughPrefixes.Count > 0 + && _options.JwtPassthroughPrefixes.Any(prefix => + context.Request.Path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)); + foreach (var header in ReservedHeaders) { + // Preserve Authorization/DPoP for routes that need JWT pass-through + if (preserveAuthHeaders && (header == "Authorization" || header == "DPoP")) + { + continue; + } + if (context.Request.Headers.ContainsKey(header)) { _logger.LogDebug( @@ -114,7 +131,7 @@ public sealed class IdentityHeaderPolicyMiddleware // In AllowAnonymous mode the Gateway cannot validate identity claims. // Pass through the client-provided tenant so the upstream service // can validate it against the JWT's own tenant claim. - var passThruTenant = !string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : null; + var passThruTenant = !string.IsNullOrWhiteSpace(clientTenant) ? clientTenant.Trim() : "default"; return new IdentityContext { @@ -192,9 +209,37 @@ public sealed class IdentityHeaderPolicyMiddleware } } + // Expand coarse OIDC scopes to fine-grained service scopes. + // This bridges the gap between Authority-registered scopes (e.g. "scheduler:read") + // and the fine-grained scopes that downstream services expect (e.g. "scheduler.runs.read"). + ExpandCoarseScopes(scopes); + return scopes; } + /// + /// Expands coarse OIDC scopes into fine-grained service scopes. + /// Pattern: "{service}:{action}" expands to "{service}.{resource}.{action}" for known resources. + /// + private static void ExpandCoarseScopes(HashSet scopes) + { + // scheduler:read -> scheduler.schedules.read, scheduler.runs.read + // scheduler:operate -> scheduler.schedules.write, scheduler.runs.write, scheduler.runs.preview, scheduler.runs.manage + if (scopes.Contains("scheduler:read")) + { + scopes.Add("scheduler.schedules.read"); + scopes.Add("scheduler.runs.read"); + } + + if (scopes.Contains("scheduler:operate")) + { + scopes.Add("scheduler.schedules.write"); + scopes.Add("scheduler.runs.write"); + scopes.Add("scheduler.runs.preview"); + scopes.Add("scheduler.runs.manage"); + } + } + private void StoreIdentityContext(HttpContext context, IdentityContext identity) { context.Items[GatewayContextKeys.IsAnonymous] = identity.IsAnonymous; @@ -248,6 +293,7 @@ public sealed class IdentityHeaderPolicyMiddleware if (!string.IsNullOrEmpty(identity.Tenant)) { headers["X-StellaOps-Tenant"] = identity.Tenant; + headers["X-Tenant-Id"] = identity.Tenant; if (_options.EnableLegacyHeaders) { headers["X-Stella-Tenant"] = identity.Tenant; @@ -270,6 +316,7 @@ public sealed class IdentityHeaderPolicyMiddleware var sortedScopes = identity.Scopes.OrderBy(s => s, StringComparer.Ordinal); var scopesValue = string.Join(" ", sortedScopes); headers["X-StellaOps-Scopes"] = scopesValue; + headers["X-Scopes"] = scopesValue; if (_options.EnableLegacyHeaders) { headers["X-Stella-Scopes"] = scopesValue; @@ -279,6 +326,7 @@ public sealed class IdentityHeaderPolicyMiddleware { // Explicit empty scopes for anonymous to prevent ambiguity headers["X-StellaOps-Scopes"] = string.Empty; + headers["X-Scopes"] = string.Empty; if (_options.EnableLegacyHeaders) { headers["X-Stella-Scopes"] = string.Empty; @@ -347,4 +395,13 @@ public sealed class IdentityHeaderPolicyOptions /// Default: false (forbidden for security). /// public bool AllowScopeHeaderOverride { get; set; } = false; + + /// + /// Route prefixes where Authorization and DPoP headers should be preserved + /// (passed through to the upstream service) instead of stripped. + /// Use this for upstream services that require JWT validation themselves + /// (e.g., Authority admin API at /console). + /// Default: empty (strip auth headers for all routes). + /// + public List JwtPassthroughPrefixes { get; set; } = []; } diff --git a/src/Router/StellaOps.Gateway.WebService/Program.cs b/src/Router/StellaOps.Gateway.WebService/Program.cs index 270aa516b..10a750a93 100644 --- a/src/Router/StellaOps.Gateway.WebService/Program.cs +++ b/src/Router/StellaOps.Gateway.WebService/Program.cs @@ -124,7 +124,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(new IdentityHeaderPolicyOptions { EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders, - AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader + AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader, + JwtPassthroughPrefixes = bootstrapOptions.Routes + .Where(r => r.PreserveAuthHeaders) + .Select(r => r.Path) + .ToList() }); // Route table: resolver + error routes + HTTP client for reverse proxy @@ -222,6 +226,20 @@ static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOption } }); + // Configure the OIDC metadata HTTP client to accept self-signed certificates + // (Authority uses a dev cert in Docker) + if (!authOptions.Authority.RequireHttpsMetadata) + { + builder.Services.ConfigureHttpClientDefaults(clientBuilder => + { + clientBuilder.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }); + }); + } + if (authOptions.Authority.RequiredScopes.Count > 0) { builder.Services.AddAuthorization(config => diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 977e0d1a9..be470c5b7 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -43,9 +43,9 @@ "MtlsEnabled": false, "AllowAnonymous": true, "Authority": { - "Issuer": "", - "RequireHttpsMetadata": true, - "MetadataAddress": "", + "Issuer": "https://authority.stella-ops.local", + "RequireHttpsMetadata": false, + "MetadataAddress": "https://authority.stella-ops.local/.well-known/openid-configuration", "Audiences": [], "RequiredScopes": [] } @@ -66,7 +66,7 @@ }, "Routes": [ { "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator" }, - { "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" }, + { "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex" }, { "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" }, { "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" }, { "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" }, @@ -78,7 +78,7 @@ { "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/audit-bundles" }, { "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy" }, - { "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk" }, + { "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" }, { "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator" }, { "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases" }, @@ -87,15 +87,16 @@ { "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" }, { "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings" }, { "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" }, + { "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy" }, { "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" }, { "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" }, { "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" }, { "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" }, { "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" }, - { "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" }, + { "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex" }, { "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" }, - { "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "https://authority.stella-ops.local/api/v1/authority" }, - { "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust" }, + { "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "https://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" }, { "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" }, { "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" }, @@ -127,15 +128,15 @@ { "Type": "ReverseProxy", "Path": "/api/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator" }, { "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" }, { "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" }, - { "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" }, + { "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "https://vexhub.stella-ops.local/api/vex" }, { "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" }, { "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" }, { "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" }, - { "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local" }, - { "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known" }, - { "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks" }, - { "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority" }, - { "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console" }, + { "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "https://authority.stella-ops.local", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "https://authority.stella-ops.local/.well-known", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "https://authority.stella-ops.local/jwks", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "/gateway", "TranslatesTo": "http://gateway.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" }, @@ -148,7 +149,7 @@ { "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" }, - { "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" }, + { "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "https://vexhub.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" }, @@ -175,7 +176,6 @@ { "Type": "ReverseProxy", "Path": "/airgapController", "TranslatesTo": "http://airgap-controller.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/airgapTime", "TranslatesTo": "http://airgap-time.stella-ops.local" }, { "Type": "ReverseProxy", "Path": "/smremote", "TranslatesTo": "http://smremote.stella-ops.local" }, - { "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" }, { "Type": "StaticFiles", "Path": "/", "TranslatesTo": "/app/wwwroot", "Headers": { "x-spa-fallback": "true" } }, { "Type": "NotFoundPage", "Path": "/_error/404", "TranslatesTo": "/app/wwwroot/index.html" }, { "Type": "ServerErrorPage", "Path": "/_error/500", "TranslatesTo": "/app/wwwroot/index.html" } diff --git a/src/Router/__Libraries/StellaOps.Router.Gateway/Configuration/StellaOpsRoute.cs b/src/Router/__Libraries/StellaOps.Router.Gateway/Configuration/StellaOpsRoute.cs index 1abdf0830..63be81bbc 100644 --- a/src/Router/__Libraries/StellaOps.Router.Gateway/Configuration/StellaOpsRoute.cs +++ b/src/Router/__Libraries/StellaOps.Router.Gateway/Configuration/StellaOpsRoute.cs @@ -22,4 +22,11 @@ public sealed class StellaOpsRoute public string? TranslatesTo { get; set; } public Dictionary Headers { get; set; } = new(); + + /// + /// When true, the gateway preserves Authorization and DPoP headers instead + /// of stripping them. Use for upstream services that perform their own JWT + /// validation (e.g., Authority admin API). + /// + public bool PreserveAuthHeaders { get; set; } } diff --git a/src/Scheduler/StellaOps.Scheduler.WebService/Auth/HeaderScopeAuthorizer.cs b/src/Scheduler/StellaOps.Scheduler.WebService/Auth/HeaderScopeAuthorizer.cs index 819cd4a81..504930f38 100644 --- a/src/Scheduler/StellaOps.Scheduler.WebService/Auth/HeaderScopeAuthorizer.cs +++ b/src/Scheduler/StellaOps.Scheduler.WebService/Auth/HeaderScopeAuthorizer.cs @@ -4,7 +4,7 @@ namespace StellaOps.Scheduler.WebService.Auth; internal sealed class HeaderScopeAuthorizer : IScopeAuthorizer { - private const string ScopeHeader = "X-Scopes"; + private const string ScopeHeader = "X-StellaOps-Scopes"; public void EnsureScope(HttpContext context, string requiredScope) { @@ -23,9 +23,30 @@ internal sealed class HeaderScopeAuthorizer : IScopeAuthorizer .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToHashSet(StringComparer.OrdinalIgnoreCase); - if (!scopes.Contains(requiredScope)) + if (scopes.Contains(requiredScope)) { - throw new InvalidOperationException($"Missing required scope '{requiredScope}'."); + return; } + + // Hierarchical match: fine-grained scope "scheduler.runs.read" is satisfied + // by OIDC coarse-grained scope "scheduler:read" or "scheduler:admin". + // Format: "{service}.{resource}.{action}" -> check "{service}:{action}" and "{service}:admin" + var dotParts = requiredScope.Split('.'); + if (dotParts.Length >= 2) + { + var service = dotParts[0]; + var action = dotParts[^1]; + if (scopes.Contains($"{service}:{action}") || scopes.Contains($"{service}:admin")) + { + return; + } + // Also check "operate" scope for write/manage actions + if (action is "write" or "manage" or "preview" && scopes.Contains($"{service}:operate")) + { + return; + } + } + + throw new InvalidOperationException($"Missing required scope '{requiredScope}'."); } } diff --git a/src/Signals/__Libraries/StellaOps.Signals.Ebpf/Schema/RuntimeCallEvent.cs b/src/Signals/__Libraries/StellaOps.Signals.Ebpf/Schema/RuntimeCallEvent.cs index 6979d5b0f..9f3b1e1b5 100644 --- a/src/Signals/__Libraries/StellaOps.Signals.Ebpf/Schema/RuntimeCallEvent.cs +++ b/src/Signals/__Libraries/StellaOps.Signals.Ebpf/Schema/RuntimeCallEvent.cs @@ -204,6 +204,42 @@ public sealed record ObservedCallPath public IReadOnlyList? BinaryOffsets { get; init; } } +/// +/// Metadata describing which BTF source was selected for probe loading. +/// +public sealed record RuntimeBtfSelection +{ + /// + /// Selected BTF source kind (kernel, external-vmlinux, split-btf, unsupported, unavailable). + /// + public required string SourceKind { get; init; } + + /// + /// Absolute path of the selected BTF source. + /// + public string? SourcePath { get; init; } + + /// + /// SHA-256 digest of the selected BTF source. + /// + public string? SourceDigest { get; init; } + + /// + /// Deterministic reason for the selected source. + /// + public required string SelectionReason { get; init; } + + /// + /// Kernel release used for BTF lookup. + /// + public required string KernelRelease { get; init; } + + /// + /// Kernel architecture used for BTF lookup. + /// + public required string KernelArch { get; init; } +} + /// /// Summary of runtime signals collected for a container. /// @@ -265,6 +301,11 @@ public sealed record RuntimeSignalSummary /// Combined hash of all observed paths for summary-level identity. /// public string? CombinedPathHash { get; init; } + + /// + /// BTF source metadata selected for this runtime collection. + /// + public RuntimeBtfSelection? BtfSelection { get; init; } } /// diff --git a/src/Web/StellaOps.Web/angular.json b/src/Web/StellaOps.Web/angular.json index 86cda742b..339561027 100644 --- a/src/Web/StellaOps.Web/angular.json +++ b/src/Web/StellaOps.Web/angular.json @@ -33,6 +33,7 @@ "assets": [ "src/favicon.ico", "src/assets", + "src/manifest.webmanifest", { "glob": "config.json", "input": "src/config", diff --git a/src/Web/StellaOps.Web/debug-auth.mjs b/src/Web/StellaOps.Web/debug-auth.mjs new file mode 100644 index 000000000..7c9f41ed1 --- /dev/null +++ b/src/Web/StellaOps.Web/debug-auth.mjs @@ -0,0 +1,114 @@ +import { chromium } from 'playwright'; + +const BASE = 'http://127.1.0.5'; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + // Step 1: Sign in + console.log('=== SIGNING IN ==='); + await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 15000 }); + + const signInBtn = page.locator('button:has-text("Sign In"), a:has-text("Sign In"), [routerLink*="auth"]').first(); + try { await signInBtn.click({ timeout: 5000 }); } catch { await page.goto(BASE + '/auth/login', { waitUntil: 'networkidle', timeout: 10000 }); } + await page.waitForTimeout(2000); + + try { + await page.locator('input[name="Username"], input[name="username"], input[type="text"]').first().fill('admin', { timeout: 5000 }); + await page.locator('input[name="Password"], input[name="password"], input[type="password"]').first().fill('Admin@Stella2026!'); + await page.locator('button[type="submit"], button:has-text("Log in"), button:has-text("Login"), button:has-text("Sign in")').first().click(); + await page.waitForTimeout(4000); + } catch (e) { + console.log('Login error: ' + e.message); + } + + console.log('After login: ' + page.url()); + + // Step 2: Check auth session state + const authState = await page.evaluate(() => { + // Check sessionStorage and localStorage for tokens + const keys = []; + for (let i = 0; i < sessionStorage.length; i++) keys.push('session:' + sessionStorage.key(i)); + for (let i = 0; i < localStorage.length; i++) keys.push('local:' + localStorage.key(i)); + return { keys, url: window.location.href }; + }); + console.log('Storage keys:', JSON.stringify(authState.keys)); + + // Step 3: Navigate to scheduler and capture FULL request details + console.log('\n=== CAPTURING SCHEDULER REQUEST ==='); + + page.on('request', (request) => { + const url = request.url(); + if (url.includes('/scheduler/') || url.includes('/api/v1/scheduler')) { + console.log('\nREQUEST:'); + console.log(' URL: ' + url); + console.log(' Method: ' + request.method()); + const headers = request.headers(); + console.log(' Authorization: ' + (headers['authorization'] || 'NONE')); + console.log(' DPoP: ' + (headers['dpop'] ? headers['dpop'].substring(0, 80) + '...' : 'NONE')); + console.log(' X-StellaOps-Tenant: ' + (headers['x-stellaops-tenant'] || 'NONE')); + console.log(' X-Tenant-Id: ' + (headers['x-tenant-id'] || 'NONE')); + console.log(' X-Scopes: ' + (headers['x-scopes'] || 'not set by client')); + } + }); + + page.on('response', async (response) => { + const url = response.url(); + if (url.includes('/scheduler/') || url.includes('/api/v1/scheduler')) { + console.log('\nRESPONSE:'); + console.log(' URL: ' + url); + console.log(' Status: ' + response.status()); + try { + const body = await response.text(); + console.log(' Body: ' + body.substring(0, 300)); + } catch {} + } + }); + + // Also capture token endpoint requests + page.on('request', (request) => { + const url = request.url(); + if (url.includes('/connect/token') || url.includes('/authority/connect/token')) { + console.log('\nTOKEN REQUEST: ' + url); + console.log(' Method: ' + request.method()); + } + }); + page.on('response', async (response) => { + const url = response.url(); + if (url.includes('/connect/token') || url.includes('/authority/connect/token')) { + console.log('TOKEN RESPONSE: ' + response.status()); + } + }); + + await page.evaluate((r) => { + window.history.pushState({}, '', r); + window.dispatchEvent(new PopStateEvent('popstate')); + }, '/operations/scheduler'); + + await page.waitForTimeout(5000); + + // Step 4: Also check what the Angular app thinks its auth state is + const appAuthState = await page.evaluate(() => { + try { + // Try to access Angular's injector + const appRef = window.ng?.getComponent(document.querySelector('app-root')); + return { hasAppRef: !!appRef }; + } catch { + return { hasAppRef: false }; + } + }); + console.log('\nApp auth state:', JSON.stringify(appAuthState)); + + // Check console errors + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warn') { + console.log('CONSOLE [' + msg.type() + ']: ' + msg.text().substring(0, 200)); + } + }); + + await page.waitForTimeout(2000); + + await browser.close(); +})(); diff --git a/src/Web/StellaOps.Web/probe-services.mjs b/src/Web/StellaOps.Web/probe-services.mjs new file mode 100644 index 000000000..b95bb6618 --- /dev/null +++ b/src/Web/StellaOps.Web/probe-services.mjs @@ -0,0 +1,65 @@ +import { chromium } from 'playwright'; + +const BASE = 'http://127.1.0.5'; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + // Sign in + console.log('=== SIGNING IN ==='); + await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 15000 }); + const signInBtn = page.locator('button:has-text("Sign In"), a:has-text("Sign In")').first(); + try { await signInBtn.click({ timeout: 5000 }); } catch {} + await page.waitForTimeout(2000); + try { + await page.locator('input[name="Username"], input[type="text"]').first().fill('admin', { timeout: 5000 }); + await page.locator('input[type="password"]').first().fill('Admin@Stella2026!'); + await page.locator('button[type="submit"]').first().click(); + await page.waitForTimeout(4000); + } catch (e) { console.log('Login error: ' + e.message); } + console.log('Signed in: ' + page.url()); + + // Probe specific failing pages + const failPages = [ + '/operations/scheduler', + '/operations/notifications', + '/evidence/bundles', + '/policy', + ]; + + for (const route of failPages) { + const apiCalls = []; + + page.on('response', async (response) => { + const url = response.url(); + if (url.startsWith(BASE) && !url.includes('.js') && !url.includes('.css') && !url.includes('.html') && !url.includes('/config.json') && !url.includes('.ico')) { + const path = new URL(url).pathname; + if (path.startsWith('/api/') || path.startsWith('/v1/') || path.startsWith('/scheduler/') || + path.startsWith('/doctor/') || path.startsWith('/console/') || path.startsWith('/health')) { + let body = ''; + try { body = await response.text(); } catch {} + apiCalls.push({ path, status: response.status(), body: body.substring(0, 200) }); + } + } + }); + + await page.evaluate((r) => { + window.history.pushState({}, '', r); + window.dispatchEvent(new PopStateEvent('popstate')); + }, route); + + await page.waitForTimeout(4000); + page.removeAllListeners('response'); + + console.log('\n--- ' + route + ' ---'); + for (const c of apiCalls) { + console.log(' ' + c.status + ' ' + c.path); + if (c.status >= 400) console.log(' Body: ' + c.body); + } + if (apiCalls.length === 0) console.log(' NO API CALLS'); + } + + await browser.close(); +})(); diff --git a/src/Web/StellaOps.Web/proxy.conf.json b/src/Web/StellaOps.Web/proxy.conf.json index 3ce635df6..2ab7fba18 100644 --- a/src/Web/StellaOps.Web/proxy.conf.json +++ b/src/Web/StellaOps.Web/proxy.conf.json @@ -1,54 +1,54 @@ { "/envsettings.json": { - "target": "http://127.1.0.3:80", + "target": "http://127.1.0.5:80", "secure": false }, "/platform": { - "target": "http://127.1.0.3:80", + "target": "http://127.1.0.5:80", "secure": false }, "/api": { - "target": "http://127.1.0.3:80", + "target": "http://127.1.0.5:80", "secure": false }, "/authority": { - "target": "http://127.1.0.4:80", + "target": "http://127.1.0.5:80", "secure": false }, "/console": { - "target": "http://127.1.0.4:80", + "target": "http://127.1.0.5:80", "secure": false }, "/connect": { - "target": "http://127.1.0.4:80", + "target": "http://127.1.0.5:80", "secure": false }, "/.well-known": { - "target": "http://127.1.0.4:80", + "target": "http://127.1.0.5:80", "secure": false }, "/jwks": { - "target": "http://127.1.0.4:80", + "target": "http://127.1.0.5:80", "secure": false }, "/scanner": { - "target": "http://127.1.0.8:80", + "target": "http://127.1.0.5:80", "secure": false }, "/policyGateway": { - "target": "http://127.1.0.14:80", + "target": "http://127.1.0.5:80", "secure": false }, "/policyEngine": { - "target": "http://127.1.0.14:80", + "target": "http://127.1.0.5:80", "secure": false }, "/concelier": { - "target": "http://127.1.0.9:80", + "target": "http://127.1.0.5:80", "secure": false }, "/attestor": { - "target": "http://127.1.0.13:80", + "target": "http://127.1.0.5:80", "secure": false }, "/gateway": { @@ -56,59 +56,67 @@ "secure": false }, "/notify": { - "target": "http://127.1.0.29:80", + "target": "http://127.1.0.5:80", "secure": false }, "/scheduler": { - "target": "http://127.1.0.19:80", + "target": "http://127.1.0.5:80", "secure": false }, "/signals": { - "target": "http://127.1.0.43:80", + "target": "http://127.1.0.5:80", "secure": false }, "/excititor": { - "target": "http://127.1.0.9:80", + "target": "http://127.1.0.5:80", "secure": false }, "/findingsLedger": { - "target": "http://127.1.0.9:80", + "target": "http://127.1.0.5:80", "secure": false }, "/vexhub": { - "target": "http://127.1.0.11:80", + "target": "http://127.1.0.5:80", "secure": false }, "/vexlens": { - "target": "http://127.1.0.12:80", + "target": "http://127.1.0.5:80", "secure": false }, "/orchestrator": { - "target": "http://127.1.0.17:80", + "target": "http://127.1.0.5:80", "secure": false }, "/graph": { - "target": "http://127.1.0.20:80", + "target": "http://127.1.0.5:80", "secure": false }, "/doctor": { - "target": "http://127.1.0.26:80", + "target": "http://127.1.0.5:80", "secure": false }, "/integrations": { - "target": "http://127.1.0.42:80", + "target": "http://127.1.0.5:80", "secure": false }, "/replay": { - "target": "http://127.1.0.41:80", + "target": "http://127.1.0.5:80", "secure": false }, "/exportcenter": { - "target": "http://127.1.0.40:80", + "target": "http://127.1.0.5:80", "secure": false }, "/healthz": { - "target": "http://127.1.0.3:80", + "target": "http://127.1.0.5:80", + "secure": false + }, + "/policy": { + "target": "http://127.1.0.5:80", + "secure": false + }, + "/v1": { + "target": "http://127.1.0.5:80", "secure": false } } diff --git a/src/Web/StellaOps.Web/scan-pages.mjs b/src/Web/StellaOps.Web/scan-pages.mjs new file mode 100644 index 000000000..eb1c05276 --- /dev/null +++ b/src/Web/StellaOps.Web/scan-pages.mjs @@ -0,0 +1,105 @@ +import { chromium } from 'playwright'; + +const BASE = 'http://127.1.0.5'; + +const routes = [ + '/security', + '/security/findings', + '/security/exceptions', + '/security/vex', + '/security/vulnerabilities', + '/operations/scheduler', + '/operations/doctor', + '/operations/feeds', + '/operations/notifications', + '/operations/health', + '/evidence/bundles', + '/evidence/export', + '/releases', + '/releases/environments', + '/approvals', + '/policy', + '/policy/governance', + '/triage', + '/sources', + '/analytics', + '/settings/admin', +]; + +(async () => { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + // Step 1: Sign in + console.log('=== SIGNING IN ==='); + await page.goto(BASE + '/', { waitUntil: 'networkidle', timeout: 15000 }); + + // Click sign in button + const signInBtn = page.locator('button:has-text("Sign In"), a:has-text("Sign In"), [routerLink*="auth"]').first(); + try { + await signInBtn.click({ timeout: 5000 }); + } catch { + await page.goto(BASE + '/auth/login', { waitUntil: 'networkidle', timeout: 10000 }); + } + await page.waitForTimeout(2000); + + console.log('Login page URL: ' + page.url()); + + try { + const usernameInput = page.locator('input[name="Username"], input[name="username"], input[type="text"]').first(); + const passwordInput = page.locator('input[name="Password"], input[name="password"], input[type="password"]').first(); + + await usernameInput.fill('admin', { timeout: 5000 }); + await passwordInput.fill('Admin@Stella2026!'); + + const loginBtn = page.locator('button[type="submit"], button:has-text("Log in"), button:has-text("Login"), button:has-text("Sign in")').first(); + await loginBtn.click(); + await page.waitForTimeout(3000); + console.log('After login URL: ' + page.url()); + } catch (e) { + console.log('Login form error: ' + e.message); + } + + await page.waitForTimeout(2000); + console.log('Final URL after auth: ' + page.url()); + + // Step 2: Navigate to each route using pushState + console.log('\n=== PAGE SCAN (with fresh token) ==='); + + for (const route of routes) { + const apiCalls = []; + + const handler = (response) => { + const url = response.url(); + if (!url.includes('.js') && !url.includes('.css') && !url.includes('.ico') && + !url.includes('.png') && !url.includes('.svg') && !url.includes('.woff') && + !url.includes('/config.json') && !url.includes('.html') && + !url.startsWith('data:') && url.startsWith(BASE)) { + const path = new URL(url).pathname; + if (path.startsWith('/api/') || path.startsWith('/v1/') || path.startsWith('/platform/') || + path.startsWith('/scanner/') || path.startsWith('/policy/') || path.startsWith('/scheduler/') || + path.startsWith('/doctor/') || path.startsWith('/authority/') || path.startsWith('/console/') || + path.startsWith('/concelier/') || path.startsWith('/attestor/') || path.startsWith('/analytics') || + path.startsWith('/health')) { + apiCalls.push({ path, status: response.status() }); + } + } + }; + + page.on('response', handler); + + await page.evaluate((r) => { + window.history.pushState({}, '', r); + window.dispatchEvent(new PopStateEvent('popstate')); + }, route); + + await page.waitForTimeout(3000); + page.removeListener('response', handler); + + const callSummary = apiCalls.map(c => c.status + ' ' + c.path).join(', ') || 'NO API CALLS'; + console.log(route + ': ' + callSummary); + } + + await browser.close(); +})(); diff --git a/src/Web/StellaOps.Web/scheduler-debug.png b/src/Web/StellaOps.Web/scheduler-debug.png new file mode 100644 index 0000000000000000000000000000000000000000..9d1ff85f1caa5447f2a5a305eb294a08ab9c504b GIT binary patch literal 81099 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 D#D6g_ literal 0 HcmV?d00001 diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 75ae72143..a2fb2d5da 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -21,15 +21,17 @@ import { NOTIFY_API_BASE_URL, NOTIFY_TENANT_ID, NotifyApiHttpClient, + MockNotifyClient, } from './core/api/notify.client'; import { EXCEPTION_API, EXCEPTION_API_BASE_URL, ExceptionApiHttpClient, + MockExceptionApiService, } from './core/api/exception.client'; -import { VULNERABILITY_API } from './core/api/vulnerability.client'; +import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/vulnerability-http.client'; -import { RISK_API } from './core/api/risk.client'; +import { RISK_API, MockRiskApi } from './core/api/risk.client'; import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; import { AppConfigService } from './core/config/app-config.service'; import { BackendProbeService } from './core/config/backend-probe.service'; @@ -37,6 +39,7 @@ import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; import { AuthSessionStore } from './core/auth/auth-session.store'; import { TenantActivationService } from './core/auth/tenant-activation.service'; import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor'; +import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor'; import { seedAuthSession, type StubAuthSession } from './testing'; import { CVSS_API_BASE_URL } from './core/api/cvss.client'; import { AUTH_SERVICE } from './core/auth'; @@ -46,32 +49,38 @@ import { ADVISORY_AI_API, ADVISORY_AI_API_BASE_URL, AdvisoryAiApiHttpClient, + MockAdvisoryAiClient, } from './core/api/advisory-ai.client'; import { ADVISORY_API, ADVISORY_API_BASE_URL, AdvisoryApiHttpClient, + MockAdvisoryApiService, } from './core/api/advisories.client'; import { VEX_EVIDENCE_API, VEX_EVIDENCE_API_BASE_URL, VexEvidenceHttpClient, + MockVexEvidenceClient, } from './core/api/vex-evidence.client'; import { VEX_DECISIONS_API, VEX_DECISIONS_API_BASE_URL, VexDecisionsHttpClient, + MockVexDecisionsClient, } from './core/api/vex-decisions.client'; -import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient } from './core/api/vex-hub.client'; +import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client'; import { AUDIT_BUNDLES_API, AUDIT_BUNDLES_API_BASE_URL, AuditBundlesHttpClient, + MockAuditBundlesClient, } from './core/api/audit-bundles.client'; import { POLICY_EXCEPTIONS_API, POLICY_EXCEPTIONS_API_BASE_URL, PolicyExceptionsHttpClient, + MockPolicyExceptionsApiService, } from './core/api/policy-exceptions.client'; import { POLICY_EVIDENCE_API, @@ -81,29 +90,35 @@ import { ORCHESTRATOR_API, ORCHESTRATOR_API_BASE_URL, OrchestratorHttpClient, + MockOrchestratorClient, } from './core/api/orchestrator.client'; import { ORCHESTRATOR_CONTROL_API, OrchestratorControlHttpClient, + MockOrchestratorControlClient, } from './core/api/orchestrator-control.client'; import { FIRST_SIGNAL_API, FirstSignalHttpClient, + MockFirstSignalClient, } from './core/api/first-signal.client'; import { EXCEPTION_EVENTS_API, EXCEPTION_EVENTS_API_BASE_URL, ExceptionEventsHttpClient, + MockExceptionEventsApiService, } from './core/api/exception-events.client'; import { EVIDENCE_PACK_API, EVIDENCE_PACK_API_BASE_URL, EvidencePackHttpClient, + MockEvidencePackClient, } from './core/api/evidence-pack.client'; import { AI_RUNS_API, AI_RUNS_API_BASE_URL, AiRunsHttpClient, + MockAiRunsClient, } from './core/api/ai-runs.client'; import { RELEASE_DASHBOARD_API, @@ -115,30 +130,37 @@ import { RELEASE_ENVIRONMENT_API, RELEASE_ENVIRONMENT_API_BASE_URL, ReleaseEnvironmentHttpClient, + MockReleaseEnvironmentClient, } from './core/api/release-environment.client'; import { RELEASE_MANAGEMENT_API, ReleaseManagementHttpClient, + MockReleaseManagementClient, } from './core/api/release-management.client'; import { WORKFLOW_API, WorkflowHttpClient, + MockWorkflowClient, } from './core/api/workflow.client'; import { APPROVAL_API, ApprovalHttpClient, + MockApprovalClient, } from './core/api/approval.client'; import { DEPLOYMENT_API, DeploymentHttpClient, + MockDeploymentClient, } from './core/api/deployment.client'; import { RELEASE_EVIDENCE_API, ReleaseEvidenceHttpClient, + MockReleaseEvidenceClient, } from './core/api/release-evidence.client'; import { DOCTOR_API, HttpDoctorClient, + MockDoctorClient, } from './features/doctor/services/doctor.client'; import { WITNESS_API, @@ -148,18 +170,22 @@ import { NOTIFIER_API, NOTIFIER_API_BASE_URL, NotifierApiHttpClient, + MockNotifierClient, } from './core/api/notifier.client'; import { POLICY_ENGINE_API, PolicyEngineHttpClient, + MockPolicyEngineApi, } from './core/api/policy-engine.client'; import { TRUST_API, TrustHttpService, + MockTrustApiService, } from './core/api/trust.client'; import { VULN_ANNOTATION_API, HttpVulnAnnotationClient, + MockVulnAnnotationClient, } from './core/api/vuln-annotation.client'; import { AUTHORITY_ADMIN_API, @@ -171,16 +197,46 @@ import { SECURITY_FINDINGS_API, SECURITY_FINDINGS_API_BASE_URL, SecurityFindingsHttpClient, + MockSecurityFindingsClient, } from './core/api/security-findings.client'; import { SECURITY_OVERVIEW_API, SecurityOverviewHttpClient, + MockSecurityOverviewClient, } from './core/api/security-overview.client'; +import { + CONSOLE_VULN_API, + ConsoleVulnHttpClient, + MockConsoleVulnClient, +} from './core/api/console-vuln.client'; +import { + REACHABILITY_API, + ReachabilityClient, + MockReachabilityApi, +} from './core/api/reachability.client'; import { SCHEDULER_API, SCHEDULER_API_BASE_URL, SchedulerHttpClient, + MockSchedulerClient, } from './core/api/scheduler.client'; +import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.client'; +import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client'; +import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client'; +import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client'; +import { POLICY_GOVERNANCE_API, HttpPolicyGovernanceApi } from './core/api/policy-governance.client'; +import { POLICY_GATES_API, POLICY_GATES_API_BASE_URL, PolicyGatesHttpClient } from './core/api/policy-gates.client'; +import { RELEASE_API, ReleaseHttpClient } from './core/api/release.client'; +import { TRIAGE_EVIDENCE_API, TriageEvidenceHttpClient } from './core/api/triage-evidence.client'; +import { VERDICT_API, HttpVerdictClient } from './core/api/verdict.client'; +import { WATCHLIST_API, WatchlistHttpClient } from './core/api/watchlist.client'; +import { EVIDENCE_API, EVIDENCE_API_BASE_URL, EvidenceHttpClient } from './core/api/evidence.client'; +import { SBOM_EVIDENCE_API, SbomEvidenceService } from './features/sbom/services/sbom-evidence.service'; +import { HttpReplayClient } from './core/api/replay.client'; +import { REPLAY_API } from './features/proofs/proof-replay-dashboard.component'; +import { HttpScoreClient } from './core/api/score.client'; +import { SCORE_API } from './features/scores/score-comparison.component'; +import { AOC_API, AOC_API_BASE_URL, AOC_SOURCES_API_BASE_URL, AocHttpClient } from './core/api/aoc.client'; export const appConfig: ApplicationConfig = { providers: [ @@ -207,6 +263,11 @@ export const appConfig: ApplicationConfig = { useClass: OperatorMetadataInterceptor, multi: true, }, + { + provide: HTTP_INTERCEPTORS, + useClass: TenantHttpInterceptor, + multi: true, + }, { provide: CONCELIER_EXPORTER_API_BASE_URL, useValue: '/api/v1/concelier/exporters/trivy-db', @@ -278,6 +339,7 @@ export const appConfig: ApplicationConfig = { }, }, RiskHttpClient, + MockRiskApi, { provide: RISK_API, useExisting: RiskHttpClient, @@ -298,6 +360,7 @@ export const appConfig: ApplicationConfig = { }, }, VulnerabilityHttpClient, + MockVulnerabilityApiService, { provide: VULNERABILITY_API, useExisting: VulnerabilityHttpClient, @@ -329,6 +392,7 @@ export const appConfig: ApplicationConfig = { }, }, AdvisoryAiApiHttpClient, + MockAdvisoryAiClient, { provide: ADVISORY_AI_API, useExisting: AdvisoryAiApiHttpClient, @@ -342,6 +406,7 @@ export const appConfig: ApplicationConfig = { }, }, AdvisoryApiHttpClient, + MockAdvisoryApiService, { provide: ADVISORY_API, useExisting: AdvisoryApiHttpClient, @@ -381,11 +446,13 @@ export const appConfig: ApplicationConfig = { }, }, VexHubApiHttpClient, + MockVexHubClient, { provide: VEX_HUB_API, useExisting: VexHubApiHttpClient, }, VexEvidenceHttpClient, + MockVexEvidenceClient, { provide: VEX_EVIDENCE_API, useExisting: VexEvidenceHttpClient, @@ -399,6 +466,7 @@ export const appConfig: ApplicationConfig = { }, }, VexDecisionsHttpClient, + MockVexDecisionsClient, { provide: VEX_DECISIONS_API, useExisting: VexDecisionsHttpClient, @@ -412,6 +480,7 @@ export const appConfig: ApplicationConfig = { }, }, AuditBundlesHttpClient, + MockAuditBundlesClient, { provide: AUDIT_BUNDLES_API, useExisting: AuditBundlesHttpClient, @@ -425,6 +494,7 @@ export const appConfig: ApplicationConfig = { }, }, PolicyExceptionsHttpClient, + MockPolicyExceptionsApiService, { provide: POLICY_EXCEPTIONS_API, useExisting: PolicyExceptionsHttpClient, @@ -443,16 +513,19 @@ export const appConfig: ApplicationConfig = { }, }, OrchestratorHttpClient, + MockOrchestratorClient, { provide: ORCHESTRATOR_API, useExisting: OrchestratorHttpClient, }, OrchestratorControlHttpClient, + MockOrchestratorControlClient, { provide: ORCHESTRATOR_CONTROL_API, useExisting: OrchestratorControlHttpClient, }, FirstSignalHttpClient, + MockFirstSignalClient, { provide: FIRST_SIGNAL_API, useExisting: FirstSignalHttpClient, @@ -466,6 +539,7 @@ export const appConfig: ApplicationConfig = { }, }, ExceptionEventsHttpClient, + MockExceptionEventsApiService, { provide: EXCEPTION_EVENTS_API, useExisting: ExceptionEventsHttpClient, @@ -475,10 +549,16 @@ export const appConfig: ApplicationConfig = { deps: [AppConfigService], useFactory: (config: AppConfigService) => { const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + try { + return new URL('/api/policy', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/policy`; + } }, }, ExceptionApiHttpClient, + MockExceptionApiService, { provide: EXCEPTION_API, useExisting: ExceptionApiHttpClient, @@ -497,6 +577,7 @@ export const appConfig: ApplicationConfig = { }, }, EvidencePackHttpClient, + MockEvidencePackClient, { provide: EVIDENCE_PACK_API, useExisting: EvidencePackHttpClient, @@ -515,6 +596,7 @@ export const appConfig: ApplicationConfig = { }, }, AiRunsHttpClient, + MockAiRunsClient, { provide: AI_RUNS_API, useExisting: AiRunsHttpClient, @@ -543,11 +625,12 @@ export const appConfig: ApplicationConfig = { useValue: 'tenant-dev', }, NotifyApiHttpClient, + MockNotifyClient, { provide: NOTIFY_API, useExisting: NotifyApiHttpClient, }, - // Release Dashboard API + // Release Dashboard API (using mock - no backend endpoint yet) { provide: RELEASE_DASHBOARD_API_BASE_URL, deps: [AppConfigService], @@ -582,42 +665,49 @@ export const appConfig: ApplicationConfig = { }, }, ReleaseEnvironmentHttpClient, + MockReleaseEnvironmentClient, { provide: RELEASE_ENVIRONMENT_API, useExisting: ReleaseEnvironmentHttpClient, }, - // Release Management API (Sprint 111_003) + // Release Management API (Sprint 111_003 - using mock until backend is available) ReleaseManagementHttpClient, + MockReleaseManagementClient, { provide: RELEASE_MANAGEMENT_API, useExisting: ReleaseManagementHttpClient, }, - // Workflow API (Sprint 111_004) + // Workflow API (Sprint 111_004 - using mock until backend is available) WorkflowHttpClient, + MockWorkflowClient, { provide: WORKFLOW_API, useExisting: WorkflowHttpClient, }, - // Approval API (Sprint 111_005) + // Approval API (using mock - no backend endpoint yet) ApprovalHttpClient, + MockApprovalClient, { provide: APPROVAL_API, useExisting: ApprovalHttpClient, }, - // Deployment API (Sprint 111_006) + // Deployment API (Sprint 111_006 - using mock until backend is available) DeploymentHttpClient, + MockDeploymentClient, { provide: DEPLOYMENT_API, useExisting: DeploymentHttpClient, }, - // Release Evidence API (Sprint 111_007) + // Release Evidence API (Sprint 111_007 - using mock until backend is available) ReleaseEvidenceHttpClient, + MockReleaseEvidenceClient, { provide: RELEASE_EVIDENCE_API, useExisting: ReleaseEvidenceHttpClient, }, - // Doctor API (Sprint 20260112_001_008) + // Doctor API (HTTP paths corrected; using mock until gateway auth chain is configured) HttpDoctorClient, + MockDoctorClient, { provide: DOCTOR_API, useExisting: HttpDoctorClient, @@ -643,22 +733,28 @@ export const appConfig: ApplicationConfig = { }, }, NotifierApiHttpClient, + MockNotifierClient, { provide: NOTIFIER_API, useExisting: NotifierApiHttpClient, }, - // Policy Engine API (Bug fix: missing DI provider caused NG0201 on /policy/packs) + // Policy Engine API + PolicyEngineHttpClient, + MockPolicyEngineApi, { provide: POLICY_ENGINE_API, useExisting: PolicyEngineHttpClient, }, - // Trust API (Bug fix: missing DI provider caused NG0201 on /admin/trust) + // Trust API + TrustHttpService, + MockTrustApiService, { provide: TRUST_API, useExisting: TrustHttpService, }, - // Vuln Annotation API (Bug fix: missing DI provider caused NG0201 on /vulnerabilities/triage) + // Vuln Annotation API (using mock until backend is available) HttpVulnAnnotationClient, + MockVulnAnnotationClient, { provide: VULN_ANNOTATION_API, useExisting: HttpVulnAnnotationClient, @@ -674,27 +770,24 @@ export const appConfig: ApplicationConfig = { provide: AUTHORITY_ADMIN_API, useExisting: AuthorityAdminHttpClient, }, - // Security Findings API (scanner findings via gateway) + // Security Findings API (findings ledger via gateway) { provide: SECURITY_FINDINGS_API_BASE_URL, deps: [AppConfigService], useFactory: (config: AppConfigService) => { const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/scanner', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/scanner`; - } + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; }, }, SecurityFindingsHttpClient, + MockSecurityFindingsClient, { provide: SECURITY_FINDINGS_API, useExisting: SecurityFindingsHttpClient, }, // Security Overview API (aggregated security dashboard data) SecurityOverviewHttpClient, + MockSecurityOverviewClient, { provide: SECURITY_OVERVIEW_API, useExisting: SecurityOverviewHttpClient, @@ -706,17 +799,180 @@ export const appConfig: ApplicationConfig = { useFactory: (config: AppConfigService) => { const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; try { - return new URL('/scheduler', gatewayBase).toString(); + return new URL('/scheduler/api/v1/scheduler', gatewayBase).toString(); } catch { const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/scheduler`; + return `${normalized}/scheduler/api/v1/scheduler`; } }, }, SchedulerHttpClient, + MockSchedulerClient, { provide: SCHEDULER_API, useExisting: SchedulerHttpClient, }, + // Console Vuln API + ConsoleVulnHttpClient, + MockConsoleVulnClient, + { + provide: CONSOLE_VULN_API, + useExisting: ConsoleVulnHttpClient, + }, + // Reachability API + ReachabilityClient, + MockReachabilityApi, + { + provide: REACHABILITY_API, + useExisting: ReachabilityClient, + }, + // Analytics API + AnalyticsHttpClient, + // Feed Mirror API (Concelier backend via gateway) + { + provide: FEED_MIRROR_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/concelier', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/concelier`; + } + }, + }, + FeedMirrorHttpClient, + { + provide: FEED_MIRROR_API, + useExisting: FeedMirrorHttpClient, + }, + // Attestation Chain API + AttestationChainHttpClient, + { + provide: ATTESTATION_CHAIN_API, + useExisting: AttestationChainHttpClient, + }, + // Console Search API + ConsoleSearchHttpClient, + { + provide: CONSOLE_SEARCH_API, + useExisting: ConsoleSearchHttpClient, + }, + // Policy Governance API + HttpPolicyGovernanceApi, + { + provide: POLICY_GOVERNANCE_API, + useExisting: HttpPolicyGovernanceApi, + }, + // Policy Gates API (Policy Gateway backend) + { + provide: POLICY_GATES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/policy', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/policy`; + } + }, + }, + PolicyGatesHttpClient, + { + provide: POLICY_GATES_API, + useExisting: PolicyGatesHttpClient, + }, + // Release API (Release Orchestrator backend) + ReleaseHttpClient, + { + provide: RELEASE_API, + useExisting: ReleaseHttpClient, + }, + // Triage Evidence API + TriageEvidenceHttpClient, + { + provide: TRIAGE_EVIDENCE_API, + useExisting: TriageEvidenceHttpClient, + }, + // Verdict API + HttpVerdictClient, + { + provide: VERDICT_API, + useExisting: HttpVerdictClient, + }, + // Watchlist API + WatchlistHttpClient, + { + provide: WATCHLIST_API, + useExisting: WatchlistHttpClient, + }, + // Evidence API (Evidence Locker backend via gateway) + { + provide: EVIDENCE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/evidence', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/evidence`; + } + }, + }, + EvidenceHttpClient, + { + provide: EVIDENCE_API, + useExisting: EvidenceHttpClient, + }, + // SBOM Evidence API + SbomEvidenceService, + { + provide: SBOM_EVIDENCE_API, + useExisting: SbomEvidenceService, + }, + // Replay API + HttpReplayClient, + { + provide: REPLAY_API, + useExisting: HttpReplayClient, + }, + // Score API + HttpScoreClient, + { + provide: SCORE_API, + useExisting: HttpScoreClient, + }, + // AOC API (Attestor + Sources backend via gateway) + { + provide: AOC_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/attestor', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/attestor`; + } + }, + }, + { + provide: AOC_SOURCES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/sources', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/sources`; + } + }, + }, + AocHttpClient, + { provide: AOC_API, useExisting: AocHttpClient }, ], }; diff --git a/src/Web/StellaOps.Web/src/app/core/api/abac-overlay.client.ts b/src/Web/StellaOps.Web/src/app/core/api/abac-overlay.client.ts index 62ed5dfdd..9f7d81fe0 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/abac-overlay.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/abac-overlay.client.ts @@ -239,15 +239,10 @@ export class AbacOverlayHttpClient implements AbacOverlayApi { } private buildHeaders(tenantId: string): HttpHeaders { - let headers = new HttpHeaders() + const headers = new HttpHeaders() .set('Content-Type', 'application/json') .set('X-Tenant-Id', tenantId); - const session = this.authStore.session(); - if (session?.tokens.accessToken) { - headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`); - } - return headers; } diff --git a/src/Web/StellaOps.Web/src/app/core/api/advisories.client.ts b/src/Web/StellaOps.Web/src/app/core/api/advisories.client.ts index 6815da5f7..aa206465c 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/advisories.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/advisories.client.ts @@ -70,10 +70,7 @@ export class AdvisoryApiHttpClient implements AdvisoryApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('AdvisoryApiHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders { diff --git a/src/Web/StellaOps.Web/src/app/core/api/analytics.client.ts b/src/Web/StellaOps.Web/src/app/core/api/analytics.client.ts index bf90ac4fa..3980a76b1 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/analytics.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/analytics.client.ts @@ -2,8 +2,8 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, delay } from 'rxjs/operators'; import { AuthSessionStore } from '../auth/auth-session.store'; import { generateTraceId } from './trace.util'; import { @@ -216,3 +216,123 @@ export class AnalyticsHttpClient { return error.message || 'Unknown error'; } } + +@Injectable({ providedIn: 'root' }) +export class MockAnalyticsClient extends AnalyticsHttpClient { + override getSuppliers( + _limit?: number, + _environment?: string | null, + _options: AnalyticsRequestOptions = {} + ): Observable> { + return of(this.wrap([ + { supplier: 'Apache Foundation', componentCount: 42, artifactCount: 18, teamCount: 3, criticalVulnCount: 2, highVulnCount: 5, environments: ['dev', 'staging', 'prod'] }, + { supplier: 'Google', componentCount: 31, artifactCount: 12, teamCount: 2, criticalVulnCount: 0, highVulnCount: 3, environments: ['dev', 'staging', 'prod'] }, + { supplier: 'Microsoft', componentCount: 28, artifactCount: 9, teamCount: 4, criticalVulnCount: 1, highVulnCount: 2, environments: ['dev', 'prod'] }, + { supplier: 'Red Hat', componentCount: 19, artifactCount: 7, teamCount: 2, criticalVulnCount: 0, highVulnCount: 1, environments: ['prod'] }, + { supplier: 'Community OSS', componentCount: 87, artifactCount: 35, teamCount: 5, criticalVulnCount: 4, highVulnCount: 12, environments: ['dev', 'staging', 'prod'] }, + ])).pipe(delay(200)); + } + + override getLicenses( + _environment?: string | null, + _options: AnalyticsRequestOptions = {} + ): Observable> { + return of(this.wrap([ + { licenseConcluded: 'Apache-2.0', licenseCategory: 'permissive', componentCount: 89, artifactCount: 34, ecosystems: ['maven', 'npm'] }, + { licenseConcluded: 'MIT', licenseCategory: 'permissive', componentCount: 112, artifactCount: 45, ecosystems: ['npm', 'nuget'] }, + { licenseConcluded: 'BSD-3-Clause', licenseCategory: 'permissive', componentCount: 23, artifactCount: 11, ecosystems: ['pip', 'npm'] }, + { licenseConcluded: 'GPL-2.0-only', licenseCategory: 'copyleft', componentCount: 5, artifactCount: 2, ecosystems: ['maven'] }, + { licenseConcluded: null, licenseCategory: 'unknown', componentCount: 8, artifactCount: 3, ecosystems: ['npm'] }, + ])).pipe(delay(200)); + } + + override getVulnerabilities( + _environment?: string | null, + _minSeverity?: string | null, + _options: AnalyticsRequestOptions = {} + ): Observable> { + return of(this.wrap([ + { vulnId: 'CVE-2025-1234', severity: 'CRITICAL', cvssScore: 9.8, epssScore: 0.87, kevListed: true, fixAvailable: true, rawComponentCount: 5, rawArtifactCount: 3, effectiveComponentCount: 3, effectiveArtifactCount: 2, vexMitigated: 2 }, + { vulnId: 'CVE-2025-5678', severity: 'HIGH', cvssScore: 7.5, epssScore: 0.45, kevListed: false, fixAvailable: true, rawComponentCount: 12, rawArtifactCount: 8, effectiveComponentCount: 8, effectiveArtifactCount: 5, vexMitigated: 4 }, + { vulnId: 'CVE-2025-9012', severity: 'HIGH', cvssScore: 7.2, epssScore: 0.32, kevListed: false, fixAvailable: false, rawComponentCount: 3, rawArtifactCount: 2, effectiveComponentCount: 3, effectiveArtifactCount: 2, vexMitigated: 0 }, + { vulnId: 'CVE-2025-3456', severity: 'MEDIUM', cvssScore: 5.3, epssScore: 0.12, kevListed: false, fixAvailable: true, rawComponentCount: 7, rawArtifactCount: 4, effectiveComponentCount: 5, effectiveArtifactCount: 3, vexMitigated: 2 }, + ])).pipe(delay(200)); + } + + override getFixableBacklog( + _environment?: string | null, + _options: AnalyticsRequestOptions = {} + ): Observable> { + return of(this.wrap([ + { service: 'api-gateway', environment: 'prod', component: 'lodash', version: '4.17.20', vulnId: 'CVE-2025-1234', severity: 'CRITICAL', fixedVersion: '4.17.21' }, + { service: 'auth-service', environment: 'prod', component: 'jackson-databind', version: '2.14.0', vulnId: 'CVE-2025-5678', severity: 'HIGH', fixedVersion: '2.14.3' }, + { service: 'scanner-worker', environment: 'staging', component: 'express', version: '4.18.1', vulnId: 'CVE-2025-3456', severity: 'MEDIUM', fixedVersion: '4.18.3' }, + ])).pipe(delay(200)); + } + + override getAttestationCoverage( + _environment?: string | null, + _options: AnalyticsRequestOptions = {} + ): Observable> { + return of(this.wrap([ + { environment: 'prod', team: 'Platform', totalArtifacts: 24, withProvenance: 22, provenancePct: 91.7, slsaLevel2Plus: 18, slsa2Pct: 75.0, missingProvenance: 2 }, + { environment: 'staging', team: 'Platform', totalArtifacts: 24, withProvenance: 20, provenancePct: 83.3, slsaLevel2Plus: 15, slsa2Pct: 62.5, missingProvenance: 4 }, + { environment: 'dev', team: 'Platform', totalArtifacts: 30, withProvenance: 12, provenancePct: 40.0, slsaLevel2Plus: 8, slsa2Pct: 26.7, missingProvenance: 18 }, + ])).pipe(delay(200)); + } + + override getVulnerabilityTrends( + _environment?: string | null, + _days?: number | null, + _options: AnalyticsRequestOptions = {} + ): Observable> { + const now = new Date(); + const points: AnalyticsVulnerabilityTrendPoint[] = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + points.push({ + snapshotDate: d.toISOString().split('T')[0], + environment: 'prod', + totalVulns: 45 - Math.floor(i * 0.3) + Math.floor(Math.random() * 3), + fixableVulns: 20 - Math.floor(i * 0.2), + vexMitigated: 8 + Math.floor(i * 0.1), + netExposure: 17 - Math.floor(i * 0.1), + kevVulns: 2, + }); + } + return of(this.wrap(points)).pipe(delay(200)); + } + + override getComponentTrends( + _environment?: string | null, + _days?: number | null, + _options: AnalyticsRequestOptions = {} + ): Observable> { + const now = new Date(); + const points: AnalyticsComponentTrendPoint[] = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + points.push({ + snapshotDate: d.toISOString().split('T')[0], + environment: 'prod', + totalComponents: 207 + Math.floor(i * 0.5), + uniqueSuppliers: 18, + }); + } + return of(this.wrap(points)).pipe(delay(200)); + } + + private wrap(items: T[]): PlatformListResponse { + return { + tenantId: 'default', + actorId: 'mock', + dataAsOf: new Date().toISOString(), + cached: false, + cacheTtlSeconds: 0, + items, + count: items.length, + }; + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts b/src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts index a2b7c8cb8..47d752751 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts @@ -1,7 +1,10 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { inject, Injectable, InjectionToken } from '@angular/core'; -import { Observable, of, delay } from 'rxjs'; +import { Observable, of, delay, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; import { AppConfigService } from '../config/app-config.service'; +import { AuthSessionStore } from '../auth/auth-session.store'; +import { generateTraceId } from './trace.util'; import { AocMetrics, AocVerificationRequest, @@ -39,6 +42,128 @@ export interface AocApi { */ export const AOC_API = new InjectionToken('AOC_API'); +/** + * Base URL injection token for the AOC attestor backend. + * Defaults to '/api/v1/attestor' when not provided (gateway-relative). + */ +export const AOC_API_BASE_URL = new InjectionToken('AOC_API_BASE_URL'); + +/** + * Base URL injection token for the AOC sources backend (SBOM service). + * Defaults to '/api/v1/sources' when not provided (gateway-relative). + */ +export const AOC_SOURCES_API_BASE_URL = new InjectionToken('AOC_SOURCES_API_BASE_URL'); + +// ============================================================================ +// HTTP Implementation +// ============================================================================ + +/** + * HTTP implementation of the AocApi interface. + * + * Routes through the gateway: + * /api/v1/attestor -> attestor.stella-ops.local (dashboard, verification) + * /api/v1/attestations -> attestor.stella-ops.local (attestation queries) + * /api/v1/sources -> sbomservice.stella-ops.local (source/violation data) + */ +@Injectable({ providedIn: 'root' }) +export class AocHttpClient implements AocApi { + private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + + private readonly attestorBaseUrl: string; + private readonly attestationsBaseUrl: string; + private readonly sourcesBaseUrl: string; + + constructor() { + const attestorRaw = inject(AOC_API_BASE_URL, { optional: true }) ?? '/api/v1/attestor'; + this.attestorBaseUrl = this.normalizeUrl(attestorRaw); + this.attestationsBaseUrl = this.normalizeUrl( + attestorRaw.replace(/\/attestor\/?$/, '/attestations') + ); + this.sourcesBaseUrl = this.normalizeUrl( + inject(AOC_SOURCES_API_BASE_URL, { optional: true }) ?? '/api/v1/sources' + ); + } + + getDashboardSummary(): Observable { + const traceId = generateTraceId(); + const headers = this.buildHeaders(traceId); + + return this.http.get( + `${this.attestorBaseUrl}/dashboard/summary`, + { headers } + ).pipe( + catchError((err) => throwError(() => this.mapError(err, traceId))) + ); + } + + startVerification(): Observable { + const traceId = generateTraceId(); + const headers = this.buildHeaders(traceId); + + return this.http.post( + `${this.attestorBaseUrl}/verifications`, + {}, + { headers } + ).pipe( + catchError((err) => throwError(() => this.mapError(err, traceId))) + ); + } + + getVerificationStatus(requestId: string): Observable { + const traceId = generateTraceId(); + const headers = this.buildHeaders(traceId); + + return this.http.get( + `${this.attestorBaseUrl}/verifications/${encodeURIComponent(requestId)}`, + { headers } + ).pipe( + catchError((err) => throwError(() => this.mapError(err, traceId))) + ); + } + + getViolationsByCode(code: string): Observable { + const traceId = generateTraceId(); + const headers = this.buildHeaders(traceId); + const params = new HttpParams().set('code', code); + + return this.http.get( + `${this.attestationsBaseUrl}/violations`, + { headers, params } + ).pipe( + map((res) => Array.isArray(res) ? res : []), + catchError((err) => throwError(() => this.mapError(err, traceId))) + ); + } + + private buildHeaders(traceId: string): HttpHeaders { + const tenantId = this.authSession.getActiveTenantId() || ''; + return new HttpHeaders({ + 'X-StellaOps-Tenant': tenantId, + 'X-Stella-Trace-Id': traceId, + 'X-Stella-Request-Id': traceId, + Accept: 'application/json', + }); + } + + private mapError(err: unknown, traceId: string): Error { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return new Error( + `[${traceId}] AOC API error: ${(err as any).status} ${(err as any).statusText ?? (err as any).message}` + ); + } + if (err instanceof Error) { + return new Error(`[${traceId}] AOC API error: ${err.message}`); + } + return new Error(`[${traceId}] AOC API error: Unknown error`); + } + + private normalizeUrl(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; + } +} + @Injectable({ providedIn: 'root' }) export class AocClient { private readonly http = inject(HttpClient); diff --git a/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts b/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts index d73a60e5b..005694bbe 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/attestation-chain.client.ts @@ -290,7 +290,7 @@ export class AttestationChainMockClient implements AttestationChainApi { subjectDigest: string, options?: AttestationQueryOptions ): Observable { - return of(this.mockChain.nodes); + return of([...this.mockChain.nodes]); } getRekorEntry(uuid: string): Observable { diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts index f127ddc51..bdb4320b8 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts @@ -35,10 +35,6 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const tenant = this.resolveTenant(); const traceId = generateTraceId(); - if (!this.tenantService.authorize('audit', 'read', ['audit:read'], this.tenantService.activeProjectId() ?? undefined, traceId)) { - return throwError(() => new Error('Unauthorized: missing audit:read scope')); - } - const headers = this.buildHeaders(tenant, traceId); return this.http.get(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe( map((resp) => ({ ...resp, traceId })), @@ -50,10 +46,6 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('audit', 'write', ['audit:write'], options.projectId, traceId)) { - return throwError(() => new Error('Unauthorized: missing audit:write scope')); - } - const headers = this.buildHeaders(tenant, traceId, options.projectId); return this.http.post(`${this.baseUrl}/v1/audit-bundles`, request, { headers }).pipe( map((resp) => ({ ...resp, traceId })), @@ -65,10 +57,6 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('audit', 'read', ['audit:read'], options.projectId, traceId)) { - return throwError(() => new Error('Unauthorized: missing audit:read scope')); - } - const headers = this.buildHeaders(tenant, traceId, options.projectId); return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe( map((resp) => ({ ...resp, traceId })), @@ -80,10 +68,6 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('audit', 'read', ['audit:read'], options.projectId, traceId)) { - return throwError(() => new Error('Unauthorized: missing audit:read scope')); - } - const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/octet-stream'); return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers, @@ -100,18 +84,11 @@ export class AuditBundlesHttpClient implements AuditBundlesApi { if (projectId) headers = headers.set('X-Stella-Project', projectId); - const session = this.authSession.session(); - if (session?.tokens.accessToken) { - headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`); - } - return headers; } private resolveTenant(tenantId?: string): string { - const tenant = tenantId ?? this.tenantService.activeTenantId(); - if (!tenant) throw new Error('AuditBundlesHttpClient requires an active tenant identifier.'); - return tenant; + return tenantId ?? this.tenantService.activeTenantId() ?? 'default'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/authority-admin.client.ts b/src/Web/StellaOps.Web/src/app/core/api/authority-admin.client.ts index 68947a8d2..803fe46d8 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/authority-admin.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/authority-admin.client.ts @@ -65,12 +65,20 @@ export interface AdminTenant { // API Interface // ============================================================================ +export interface CreateUserRequest { + username: string; + email: string; + displayName: string; + roles: string[]; +} + export interface AuthorityAdminApi { listUsers(tenantId?: string): Observable; listRoles(tenantId?: string): Observable; listClients(tenantId?: string): Observable; listTokens(tenantId?: string): Observable; listTenants(): Observable; + createUser(request: CreateUserRequest): Observable; } export const AUTHORITY_ADMIN_API = new InjectionToken('AUTHORITY_ADMIN_API'); @@ -118,6 +126,12 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi { }).pipe(map(r => r.tenants ?? [])); } + createUser(request: CreateUserRequest): Observable { + return this.http.post(`${this.baseUrl}/users`, request, { + headers: this.buildHeaders(), + }); + } + private buildHeaders(tenantOverride?: string): HttpHeaders { const tenantId = (tenantOverride && tenantOverride.trim()) || @@ -182,4 +196,17 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi { ]; return of(data).pipe(delay(300)); } + + createUser(request: CreateUserRequest): Observable { + const user: AdminUser = { + id: 'u-' + Date.now(), + username: request.username, + email: request.email, + displayName: request.displayName, + roles: request.roles, + status: 'active', + createdAt: new Date().toISOString(), + }; + return of(user).pipe(delay(400)); + } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/console-export.client.ts b/src/Web/StellaOps.Web/src/app/core/api/console-export.client.ts index 77d60b77b..ee429cb7b 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/console-export.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/console-export.client.ts @@ -102,9 +102,6 @@ export class ConsoleExportClient { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ConsoleExportClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/console-search.client.ts b/src/Web/StellaOps.Web/src/app/core/api/console-search.client.ts index fcfd7110c..5c8a3d1f7 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/console-search.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/console-search.client.ts @@ -246,10 +246,7 @@ export class ConsoleSearchHttpClient implements ConsoleSearchApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ConsoleSearchClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private mapError(err: unknown, traceId: string): Error { @@ -414,7 +411,7 @@ export class MockConsoleSearchClient implements ConsoleSearchApi { // Sort items deterministically: type asc, id asc, format asc const items: DownloadManifestItem[] = [ { - type: 'advisory', + type: 'advisory' as const, id: 'CVE-2024-12345', format: 'json', url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-12345.json?sig=mock`, @@ -422,7 +419,7 @@ export class MockConsoleSearchClient implements ConsoleSearchApi { size: 4096, }, { - type: 'advisory', + type: 'advisory' as const, id: 'CVE-2024-67890', format: 'json', url: `https://downloads.local/exports/${exportId}/advisory/CVE-2024-67890.json?sig=mock`, @@ -430,7 +427,7 @@ export class MockConsoleSearchClient implements ConsoleSearchApi { size: 3072, }, { - type: 'vex', + type: 'vex' as const, id: 'vex:tenant-default:jwt-auth:5d1a', format: 'json', url: `https://downloads.local/exports/${exportId}/vex/jwt-auth-5d1a.json?sig=mock`, @@ -438,7 +435,7 @@ export class MockConsoleSearchClient implements ConsoleSearchApi { size: 2048, }, { - type: 'vuln', + type: 'vuln' as const, id: 'tenant-default:advisory-ai:sha256:5d1a', format: 'json', url: `https://downloads.local/exports/${exportId}/vuln/5d1a.json?sig=mock`, @@ -480,6 +477,6 @@ export class MockConsoleSearchClient implements ConsoleSearchApi { tenant: tenantId, }; // In production, this would be signed and base64url encoded - return Buffer.from(JSON.stringify(cursorData)).toString('base64url'); + return btoa(JSON.stringify(cursorData)); } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/console-status.client.ts b/src/Web/StellaOps.Web/src/app/core/api/console-status.client.ts index b45b57ad8..8160c98c5 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/console-status.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/console-status.client.ts @@ -86,9 +86,6 @@ export class ConsoleStatusClient { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ConsoleStatusClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/console-vex.client.ts b/src/Web/StellaOps.Web/src/app/core/api/console-vex.client.ts index d4a4778da..ec6c58520 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/console-vex.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/console-vex.client.ts @@ -211,10 +211,7 @@ export class ConsoleVexHttpClient implements ConsoleVexApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ConsoleVexClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private mapError(err: unknown, traceId: string): Error { diff --git a/src/Web/StellaOps.Web/src/app/core/api/console-vuln.client.ts b/src/Web/StellaOps.Web/src/app/core/api/console-vuln.client.ts index 1d5cf0d06..04f15281e 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/console-vuln.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/console-vuln.client.ts @@ -182,10 +182,7 @@ export class ConsoleVulnHttpClient implements ConsoleVulnApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ConsoleVulnClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private mapError(err: unknown, traceId: string): Error { diff --git a/src/Web/StellaOps.Web/src/app/core/api/cvss.client.ts b/src/Web/StellaOps.Web/src/app/core/api/cvss.client.ts index 5dc4e8bcc..c1e458abb 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/cvss.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/cvss.client.ts @@ -108,10 +108,6 @@ export class CvssClient { } private resolveTenant(): string { - const tenant = this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('CvssClient requires an active tenant identifier.'); - } - return tenant; + return this.authSession.getActiveTenantId() ?? 'default'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts b/src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts index 5af1f3ad1..407951811 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts @@ -1,5 +1,8 @@ -import { Injectable, InjectionToken } from '@angular/core'; -import { Observable, of, delay } from 'rxjs'; +import { Injectable, InjectionToken, Inject } from '@angular/core'; +import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Observable, of, delay, firstValueFrom, catchError, throwError } from 'rxjs'; + +import { AuthSessionStore } from '../auth/auth-session.store'; import { EvidenceData, @@ -22,6 +25,110 @@ export interface EvidenceApi { } export const EVIDENCE_API = new InjectionToken('EVIDENCE_API'); +export const EVIDENCE_API_BASE_URL = new InjectionToken('EVIDENCE_API_BASE_URL'); + +// ============================================================================ +// HTTP Implementation +// ============================================================================ + +@Injectable() +export class EvidenceHttpClient implements EvidenceApi { + constructor( + private readonly http: HttpClient, + @Inject(EVIDENCE_API_BASE_URL) private readonly baseUrl: string, + private readonly authSession: AuthSessionStore, + ) {} + + getEvidenceForAdvisory(advisoryId: string): Observable { + return this.http + .get( + `${this.baseUrl}/api/v1/evidence/advisories/${encodeURIComponent(advisoryId)}`, + { headers: this.buildHeaders() }, + ) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + getObservation(observationId: string): Observable { + return this.http + .get( + `${this.baseUrl}/api/v1/evidence/observations/${encodeURIComponent(observationId)}`, + { headers: this.buildHeaders() }, + ) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + getLinkset(linksetId: string): Observable { + return this.http + .get( + `${this.baseUrl}/api/v1/evidence/linksets/${encodeURIComponent(linksetId)}`, + { headers: this.buildHeaders() }, + ) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + getPolicyEvidence(advisoryId: string): Observable { + return this.http + .get( + `${this.baseUrl}/api/v1/evidence/advisories/${encodeURIComponent(advisoryId)}/policy`, + { headers: this.buildHeaders() }, + ) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable { + const segment = type === 'observation' ? 'observations' : 'linksets'; + return this.http + .get( + `${this.baseUrl}/api/v1/evidence/${segment}/${encodeURIComponent(id)}/raw`, + { + headers: this.buildHeaders().set('Accept', 'application/json'), + responseType: 'blob', + }, + ) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise { + const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip'; + const params = new HttpParams().set('format', format); + + return firstValueFrom( + this.http + .get( + `${this.baseUrl}/api/v1/evidence/advisories/${encodeURIComponent(advisoryId)}/export`, + { + headers: this.buildHeaders().set('Accept', mimeType), + params, + responseType: 'blob', + }, + ) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))), + ); + } + + private buildHeaders(): HttpHeaders { + const tenantId = this.authSession.getActiveTenantId(); + const headers: Record = {}; + if (tenantId) { + headers['X-StellaOps-Tenant'] = tenantId; + } + return new HttpHeaders(headers); + } + + private normalizeError(err: unknown): Error { + if (err instanceof HttpErrorResponse) { + return new Error( + `Evidence API request failed: ${err.status} ${err.statusText ?? 'Unknown'}`, + ); + } + if (err instanceof Error) return err; + return new Error('Evidence API request failed'); + } +} + +// ============================================================================ +// Mock Implementation +// ============================================================================ // Mock data for development const MOCK_OBSERVATIONS: Observation[] = [ diff --git a/src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts b/src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts index 38b596cee..47e6ab749 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts @@ -66,10 +66,7 @@ export class ExceptionEventsHttpClient implements ExceptionEventsApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ExceptionEventsHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/exception.client.ts b/src/Web/StellaOps.Web/src/app/core/api/exception.client.ts index ee259a47b..681cfa6a9 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/exception.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/exception.client.ts @@ -45,7 +45,7 @@ export class ExceptionApiHttpClient implements ExceptionApi { const tenant = this.resolveTenant(options?.tenantId); const traceId = options?.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('exception', 'read', ['exception:read'], options?.projectId, traceId)) { + if (!this.tenantService.authorize('exception', 'read', ['exceptions:read'], options?.projectId, traceId)) { return throwError(() => new Error('Unauthorized: missing exception:read scope')); } @@ -81,7 +81,7 @@ export class ExceptionApiHttpClient implements ExceptionApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('exception', 'read', ['exception:read'], options.projectId, traceId)) { + if (!this.tenantService.authorize('exception', 'read', ['exceptions:read'], options.projectId, traceId)) { return throwError(() => new Error('Unauthorized: missing exception:read scope')); } @@ -94,7 +94,7 @@ export class ExceptionApiHttpClient implements ExceptionApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('exception', 'write', ['exception:write'], options.projectId, traceId)) { + if (!this.tenantService.authorize('exception', 'write', ['exceptions:read'], options.projectId, traceId)) { return throwError(() => new Error('Unauthorized: missing exception:write scope')); } @@ -107,7 +107,7 @@ export class ExceptionApiHttpClient implements ExceptionApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('exception', 'write', ['exception:write'], options.projectId, traceId)) { + if (!this.tenantService.authorize('exception', 'write', ['exceptions:read'], options.projectId, traceId)) { return throwError(() => new Error('Unauthorized: missing exception:write scope')); } @@ -120,7 +120,7 @@ export class ExceptionApiHttpClient implements ExceptionApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('exception', 'write', ['exception:write'], options.projectId, traceId)) { + if (!this.tenantService.authorize('exception', 'write', ['exceptions:read'], options.projectId, traceId)) { return throwError(() => new Error('Unauthorized: missing exception:write scope')); } @@ -133,10 +133,10 @@ export class ExceptionApiHttpClient implements ExceptionApi { const tenant = this.resolveTenant(transition.tenantId); const traceId = transition.traceId ?? generateTraceId(); - const requiredScopes: ('exception:write' | 'exception:approve')[] = + const requiredScopes: ('exceptions:read' | 'exceptions:approve')[] = transition.newStatus === 'approved' || transition.newStatus === 'rejected' || transition.newStatus === 'revoked' - ? ['exception:approve'] - : ['exception:write']; + ? ['exceptions:approve'] + : ['exceptions:read']; if (!this.tenantService.authorize('exception', 'transition', requiredScopes, transition.projectId, traceId)) { return throwError(() => new Error(`Unauthorized: missing ${requiredScopes.join(' or ')} scope`)); @@ -158,7 +158,7 @@ export class ExceptionApiHttpClient implements ExceptionApi { const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); - if (!this.tenantService.authorize('exception', 'read', ['exception:read'], options.projectId, traceId)) { + if (!this.tenantService.authorize('exception', 'read', ['exceptions:read'], options.projectId, traceId)) { return throwError(() => new Error('Unauthorized: missing exception:read scope')); } @@ -168,11 +168,7 @@ export class ExceptionApiHttpClient implements ExceptionApi { } private resolveTenant(tenantId?: string): string { - const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ExceptionApiHttpClient requires an active tenant identifier.'); - } - return tenant; + return (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId() || 'default'; } private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders { diff --git a/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts b/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts index 6406401a9..1e50cc14f 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/export-center.client.ts @@ -205,10 +205,7 @@ export class ExportCenterHttpClient implements ExportCenterApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('ExportCenterClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private mapError(err: unknown, traceId: string): Error { diff --git a/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts b/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts index 21f13258f..0ef2eb415 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts @@ -1,4 +1,5 @@ -import { Injectable, InjectionToken } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Inject, Injectable, InjectionToken } from '@angular/core'; import { Observable, of, delay, throwError } from 'rxjs'; import { FeedMirror, @@ -24,6 +25,7 @@ import { * Injection token for Feed Mirror API client. */ export const FEED_MIRROR_API = new InjectionToken('FEED_MIRROR_API'); +export const FEED_MIRROR_API_BASE_URL = new InjectionToken('FEED_MIRROR_API_BASE_URL'); /** * Feed Mirror API interface. @@ -333,6 +335,192 @@ const mockOfflineSyncStatus: OfflineSyncStatus = { ], }; +// ============================================================================ +// HTTP API Implementation +// ============================================================================ + +/** + * HTTP Feed Mirror client. + * Communicates with the Concelier backend via the gateway at /api/v1/concelier. + */ +@Injectable({ providedIn: 'root' }) +export class FeedMirrorHttpClient implements FeedMirrorApi { + constructor( + private readonly http: HttpClient, + @Inject(FEED_MIRROR_API_BASE_URL) private readonly baseUrl: string + ) {} + + // ---- Mirror operations ---- + + listMirrors(filter?: FeedMirrorFilter): Observable { + let params = new HttpParams(); + if (filter?.feedTypes?.length) { + params = params.set('feedTypes', filter.feedTypes.join(',')); + } + if (filter?.syncStatuses?.length) { + params = params.set('syncStatuses', filter.syncStatuses.join(',')); + } + if (filter?.enabled !== undefined) { + params = params.set('enabled', String(filter.enabled)); + } + if (filter?.searchTerm) { + params = params.set('search', filter.searchTerm); + } + return this.http.get(`${this.baseUrl}/mirrors`, { params }); + } + + getMirror(mirrorId: string): Observable { + return this.http.get( + `${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}` + ); + } + + updateMirrorConfig(mirrorId: string, config: MirrorConfigUpdate): Observable { + return this.http.patch( + `${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}`, + config + ); + } + + triggerSync(request: MirrorSyncRequest): Observable { + return this.http.post( + `${this.baseUrl}/mirrors/${encodeURIComponent(request.mirrorId)}/sync`, + request + ); + } + + // ---- Snapshot operations ---- + + listSnapshots(mirrorId: string): Observable { + return this.http.get( + `${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}/snapshots` + ); + } + + getSnapshot(snapshotId: string): Observable { + return this.http.get( + `${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}` + ); + } + + downloadSnapshot(snapshotId: string): Observable { + return this.http.post( + `${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}/download`, + {} + ); + } + + pinSnapshot(snapshotId: string, pinned: boolean): Observable { + return this.http.patch( + `${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`, + { isPinned: pinned } + ); + } + + deleteSnapshot(snapshotId: string): Observable { + return this.http.delete( + `${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}` + ); + } + + updateRetentionConfig(config: SnapshotRetentionConfig): Observable { + return this.http.put( + `${this.baseUrl}/mirrors/${encodeURIComponent(config.mirrorId)}/retention`, + config + ); + } + + getRetentionConfig(mirrorId: string): Observable { + return this.http.get( + `${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}/retention` + ); + } + + // ---- AirGap bundle operations ---- + + listBundles(): Observable { + return this.http.get(`${this.baseUrl}/bundles`); + } + + getBundle(bundleId: string): Observable { + return this.http.get( + `${this.baseUrl}/bundles/${encodeURIComponent(bundleId)}` + ); + } + + createBundle(request: AirGapBundleRequest): Observable { + return this.http.post(`${this.baseUrl}/bundles`, request); + } + + deleteBundle(bundleId: string): Observable { + return this.http.delete( + `${this.baseUrl}/bundles/${encodeURIComponent(bundleId)}` + ); + } + + downloadBundle(bundleId: string): Observable { + return this.http.post( + `${this.baseUrl}/bundles/${encodeURIComponent(bundleId)}/download`, + {} + ); + } + + // ---- AirGap import operations ---- + + validateImport(file: File): Observable { + const formData = new FormData(); + formData.append('file', file, file.name); + return this.http.post( + `${this.baseUrl}/imports/validate`, + formData + ); + } + + startImport(bundleId: string): Observable { + return this.http.post( + `${this.baseUrl}/imports`, + { bundleId } + ); + } + + getImportProgress(importId: string): Observable { + return this.http.get( + `${this.baseUrl}/imports/${encodeURIComponent(importId)}` + ); + } + + // ---- Version lock operations ---- + + listVersionLocks(): Observable { + return this.http.get(`${this.baseUrl}/version-locks`); + } + + getVersionLock(feedType: FeedType): Observable { + return this.http.get( + `${this.baseUrl}/version-locks/${encodeURIComponent(feedType)}` + ); + } + + setVersionLock(request: FeedVersionLockRequest): Observable { + return this.http.put( + `${this.baseUrl}/version-locks/${encodeURIComponent(request.feedType)}`, + request + ); + } + + removeVersionLock(lockId: string): Observable { + return this.http.delete( + `${this.baseUrl}/version-locks/${encodeURIComponent(lockId)}` + ); + } + + // ---- Offline status ---- + + getOfflineSyncStatus(): Observable { + return this.http.get(`${this.baseUrl}/offline-status`); + } +} + // ============================================================================ // Mock API Implementation // ============================================================================ diff --git a/src/Web/StellaOps.Web/src/app/core/api/findings-ledger.client.ts b/src/Web/StellaOps.Web/src/app/core/api/findings-ledger.client.ts index b2faad005..27b75ba1d 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/findings-ledger.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/findings-ledger.client.ts @@ -329,11 +329,6 @@ export class FindingsLedgerHttpClient implements FindingsLedgerApi { if (projectId) headers = headers.set('X-Stella-Project', projectId); if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId); - const session = this.authStore.session(); - if (session?.tokens.accessToken) { - headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`); - } - return headers; } @@ -341,10 +336,7 @@ export class FindingsLedgerHttpClient implements FindingsLedgerApi { const tenant = tenantId?.trim() || this.tenantService.activeTenantId() || this.authStore.getActiveTenantId(); - if (!tenant) { - throw new Error('FindingsLedgerHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private generateCorrelationId(): string { diff --git a/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts b/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts index 3df9ffe12..cbf50d520 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts @@ -104,10 +104,7 @@ export class FirstSignalHttpClient implements FirstSignalApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('FirstSignalHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders { diff --git a/src/Web/StellaOps.Web/src/app/core/api/graph-platform.client.ts b/src/Web/StellaOps.Web/src/app/core/api/graph-platform.client.ts index 7491e9e84..7ef4eeacc 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/graph-platform.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/graph-platform.client.ts @@ -260,10 +260,7 @@ export class GraphPlatformHttpClient implements GraphPlatformApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('GraphPlatformClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private mapError(err: unknown, traceId: string): Error { diff --git a/src/Web/StellaOps.Web/src/app/core/api/orchestrator-control.client.ts b/src/Web/StellaOps.Web/src/app/core/api/orchestrator-control.client.ts index 253f803fe..2a05bbff5 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/orchestrator-control.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/orchestrator-control.client.ts @@ -352,10 +352,7 @@ export class OrchestratorControlHttpClient implements OrchestratorControlApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('OrchestratorControlHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private buildHeaders( diff --git a/src/Web/StellaOps.Web/src/app/core/api/orchestrator.client.ts b/src/Web/StellaOps.Web/src/app/core/api/orchestrator.client.ts index 4acb8b2df..08df4bbf5 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/orchestrator.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/orchestrator.client.ts @@ -68,10 +68,7 @@ export class OrchestratorHttpClient implements OrchestratorApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('OrchestratorHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders { diff --git a/src/Web/StellaOps.Web/src/app/core/api/platform-health.models.ts b/src/Web/StellaOps.Web/src/app/core/api/platform-health.models.ts index 13230b12d..1b897f62d 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/platform-health.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/platform-health.models.ts @@ -234,6 +234,7 @@ export function formatLatency(ms: number): string { return `${Math.round(ms)}ms`; } -export function formatErrorRate(rate: number): string { +export function formatErrorRate(rate: number | null | undefined): string { + if (rate == null) return '0.00%'; return `${rate.toFixed(2)}%`; } diff --git a/src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts b/src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts index 2c8d75de6..d6d46e9e9 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts @@ -74,10 +74,7 @@ export class PolicyExceptionsHttpClient implements PolicyExceptionsApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('PolicyExceptionsHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders { diff --git a/src/Web/StellaOps.Web/src/app/core/api/policy-gates.client.ts b/src/Web/StellaOps.Web/src/app/core/api/policy-gates.client.ts index f9e202c2a..6f0edb20a 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/policy-gates.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/policy-gates.client.ts @@ -1,5 +1,10 @@ -import { Injectable, InjectionToken } from '@angular/core'; +import { Inject, Injectable, InjectionToken } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable, of, delay } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { AuthSessionStore } from '../auth/auth-session.store'; +import { TenantActivationService } from '../auth/tenant-activation.service'; +import { generateTraceId } from './trace.util'; import { PolicyProfile, PolicyProfileType, @@ -22,6 +27,7 @@ import { * Injection token for Policy Gates API client. */ export const POLICY_GATES_API = new InjectionToken('POLICY_GATES_API'); +export const POLICY_GATES_API_BASE_URL = new InjectionToken('POLICY_GATES_API_BASE_URL'); /** * Policy Gates API interface. @@ -438,6 +444,186 @@ const mockBundleSimulation: BundleSimulationResult = { durationMs: 320, }; +// ============================================================================= +// HTTP Implementation +// ============================================================================= + +@Injectable() +export class PolicyGatesHttpClient implements PolicyGatesApi { + constructor( + private readonly http: HttpClient, + private readonly authSession: AuthSessionStore, + private readonly tenantService: TenantActivationService, + @Inject(POLICY_GATES_API_BASE_URL) private readonly baseUrl: string + ) {} + + // ---- Profile management ---- + + listProfiles(includeBuiltin = true): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + const params: Record = {}; + if (!includeBuiltin) { + params['includeBuiltin'] = 'false'; + } + return this.http.get(`${this.baseUrl}/gate/profiles`, { + headers: this.buildHeaders(tenant, traceId), + params, + }).pipe( + catchError(() => of([] as PolicyProfile[])) + ); + } + + getProfile(profileId: string): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.get(`${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}`, { + headers: this.buildHeaders(tenant, traceId), + }); + } + + getProfileByName(name: string): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.get(`${this.baseUrl}/gate/profiles/by-name/${encodeURIComponent(name)}`, { + headers: this.buildHeaders(tenant, traceId), + }).pipe( + catchError(() => of(null)) + ); + } + + createProfile(request: CreatePolicyProfileRequest): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.post(`${this.baseUrl}/gate/profiles`, request, { + headers: this.buildHeaders(tenant, traceId), + }); + } + + updateProfile(profileId: string, request: UpdatePolicyProfileRequest): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.patch( + `${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}`, + request, + { headers: this.buildHeaders(tenant, traceId) } + ); + } + + deleteProfile(profileId: string): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.delete( + `${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}`, + { headers: this.buildHeaders(tenant, traceId) } + ).pipe( + map(() => true), + catchError(() => of(false)) + ); + } + + setDefaultProfile(profileId: string): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.post( + `${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}/set-default`, + {}, + { headers: this.buildHeaders(tenant, traceId) } + ); + } + + getEffectiveProfile(): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.get(`${this.baseUrl}/gate/profiles/effective`, { + headers: this.buildHeaders(tenant, traceId), + }); + } + + validatePolicyYaml(yaml: string): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.post( + `${this.baseUrl}/gate/profiles/validate`, + { yaml }, + { headers: this.buildHeaders(tenant, traceId) } + ); + } + + // ---- Simulation ---- + + simulate(request: PolicySimulationRequest): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.post( + `${this.baseUrl}/gate/simulate`, + request, + { headers: this.buildHeaders(tenant, traceId) } + ); + } + + simulateBundle(promotionId: string, profileIdOrName?: string): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + const body: Record = { promotionId }; + if (profileIdOrName) { + body['profileIdOrName'] = profileIdOrName; + } + return this.http.post( + `${this.baseUrl}/gate/simulate/bundle`, + body, + { headers: this.buildHeaders(tenant, traceId) } + ); + } + + // ---- Feed freshness ---- + + getFeedFreshnessSummary(): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.get(`${this.baseUrl}/gate/feeds/freshness`, { + headers: this.buildHeaders(tenant, traceId), + }); + } + + getFeedFreshness(feedName: string): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.get( + `${this.baseUrl}/gate/feeds/freshness/${encodeURIComponent(feedName)}`, + { headers: this.buildHeaders(tenant, traceId) } + ).pipe( + catchError(() => of(null)) + ); + } + + // ---- Air-gap status ---- + + getAirGapStatus(): Observable { + const traceId = generateTraceId(); + const tenant = this.resolveTenant(); + return this.http.get(`${this.baseUrl}/gate/airgap/status`, { + headers: this.buildHeaders(tenant, traceId), + }); + } + + // ---- Private helpers ---- + + private resolveTenant(tenantId?: string): string { + const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); + return tenant ?? 'default'; + } + + private buildHeaders(tenantId: string, traceId: string): HttpHeaders { + return new HttpHeaders({ + 'Content-Type': 'application/json', + 'X-StellaOps-Tenant': tenantId, + 'X-Stella-Trace-Id': traceId, + 'X-Stella-Request-Id': traceId, + }); + } +} + // ============================================================================= // Mock API Implementation // ============================================================================= diff --git a/src/Web/StellaOps.Web/src/app/core/api/policy-simulation.client.ts b/src/Web/StellaOps.Web/src/app/core/api/policy-simulation.client.ts index d1eb5fd7b..81e28018f 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/policy-simulation.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/policy-simulation.client.ts @@ -427,10 +427,7 @@ export class PolicySimulationHttpClient implements PolicySimulationApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('PolicySimulationHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private buildHeaders(tenantId: string, traceId: string): HttpHeaders { diff --git a/src/Web/StellaOps.Web/src/app/core/api/release.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release.client.ts index 3f500409c..7a7dd2d9e 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release.client.ts @@ -1,5 +1,6 @@ -import { Injectable, InjectionToken } from '@angular/core'; -import { Observable, of, delay } from 'rxjs'; +import { Injectable, InjectionToken, inject } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Observable, of, delay, catchError, throwError } from 'rxjs'; import { Release, ReleaseArtifact, @@ -28,6 +29,64 @@ export interface ReleaseApi { requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>; } +// ============================================================================ +// HTTP Client Implementation +// ============================================================================ + +@Injectable({ providedIn: 'root' }) +export class ReleaseHttpClient implements ReleaseApi { + private readonly http = inject(HttpClient); + private readonly baseUrl = '/api/releases'; + + getRelease(releaseId: string): Observable { + return this.http + .get(`${this.baseUrl}/${encodeURIComponent(releaseId)}`) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + listReleases(): Observable { + return this.http + .get(this.baseUrl) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + publishRelease(releaseId: string): Observable { + return this.http + .post(`${this.baseUrl}/${encodeURIComponent(releaseId)}/publish`, {}) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + cancelRelease(releaseId: string): Observable { + return this.http + .post(`${this.baseUrl}/${encodeURIComponent(releaseId)}/cancel`, {}) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + getFeatureFlags(): Observable { + return this.http + .get(`${this.baseUrl}/feature-flags`) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> { + return this.http + .post<{ requestId: string }>(`${this.baseUrl}/${encodeURIComponent(releaseId)}/bypass`, { reason }) + .pipe(catchError((err) => throwError(() => this.normalizeError(err)))); + } + + private normalizeError(err: unknown): Error { + if (err instanceof HttpErrorResponse) { + const message = err.error?.message ?? err.message ?? 'Release API request failed'; + const normalized = new Error(message); + (normalized as any).status = err.status; + (normalized as any).statusText = err.statusText; + return normalized; + } + if (err instanceof Error) return err; + return new Error('Release API request failed'); + } +} + // ============================================================================ // Mock Data Fixtures // ============================================================================ diff --git a/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts b/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts index 40a85cd87..506aee413 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts @@ -158,9 +158,6 @@ export class RiskHttpClient implements RiskApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('RiskHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts b/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts index 727589654..1ef5ad7f8 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/scheduler.client.ts @@ -4,7 +4,8 @@ */ import { Injectable, InjectionToken, Inject } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { delay } from 'rxjs/operators'; import { AuthSessionStore } from '../auth/auth-session.store'; import type { Schedule, @@ -126,3 +127,71 @@ export class SchedulerHttpClient implements SchedulerApi { return new HttpHeaders(headers); } } + +// ============================================================================ +// Mock Implementation +// ============================================================================ + +@Injectable({ providedIn: 'root' }) +export class MockSchedulerClient implements SchedulerApi { + private schedules: Schedule[] = [ + { id: 'sch-1', name: 'Nightly Vulnerability Sync', description: 'Synchronize vulnerability feeds from upstream sources', cronExpression: '0 2 * * *', timezone: 'UTC', enabled: true, taskType: 'vulnerability-sync', taskConfig: { sources: ['osv', 'nvd'] }, lastRunAt: '2026-02-16T02:00:00Z', nextRunAt: '2026-02-17T02:00:00Z', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-02-15T00:00:00Z', createdBy: 'admin', tags: ['security', 'nightly'], retryPolicy: { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 5000, maxDelayMs: 60000 }, concurrencyLimit: 1 }, + { id: 'sch-2', name: 'SBOM Refresh', description: 'Re-scan all registered artifacts for SBOM updates', cronExpression: '0 4 * * 0', timezone: 'UTC', enabled: true, taskType: 'sbom-refresh', taskConfig: { scope: 'all' }, lastRunAt: '2026-02-09T04:00:00Z', nextRunAt: '2026-02-16T04:00:00Z', createdAt: '2026-01-05T00:00:00Z', updatedAt: '2026-02-09T00:00:00Z', createdBy: 'admin', tags: ['sbom', 'weekly'], retryPolicy: { maxRetries: 2, backoffMultiplier: 2, initialDelayMs: 10000, maxDelayMs: 120000 }, concurrencyLimit: 2 }, + { id: 'sch-3', name: 'Advisory Update Check', description: 'Check for new security advisories from configured sources', cronExpression: '0 */6 * * *', timezone: 'UTC', enabled: true, taskType: 'advisory-update', taskConfig: { sources: ['cisa', 'mitre'] }, lastRunAt: '2026-02-16T00:00:00Z', nextRunAt: '2026-02-16T06:00:00Z', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-02-01T00:00:00Z', createdBy: 'admin', tags: ['security', 'advisories'], retryPolicy: { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 5000, maxDelayMs: 60000 }, concurrencyLimit: 1 }, + { id: 'sch-4', name: 'Evidence Export', description: 'Export evidence bundles to configured destinations', cronExpression: '0 6 1 * *', timezone: 'UTC', enabled: false, taskType: 'export', taskConfig: { destination: 's3', format: 'bundle' }, lastRunAt: '2026-02-01T06:00:00Z', nextRunAt: undefined, createdAt: '2026-01-15T00:00:00Z', updatedAt: '2026-02-15T10:00:00Z', createdBy: 'admin', tags: ['evidence', 'monthly'], retryPolicy: { maxRetries: 2, backoffMultiplier: 2, initialDelayMs: 15000, maxDelayMs: 300000 }, concurrencyLimit: 1 }, + ]; + + listSchedules(): Observable { + return of([...this.schedules]).pipe(delay(300)); + } + + getSchedule(id: string): Observable { + const s = this.schedules.find(s => s.id === id); + return of(s ?? this.schedules[0]).pipe(delay(200)); + } + + createSchedule(dto: CreateScheduleDto): Observable { + const s: Schedule = { ...dto, id: `sch-${Date.now()}`, taskConfig: dto.taskConfig ?? {}, lastRunAt: undefined, nextRunAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), createdBy: 'admin', tags: dto.tags ?? [], retryPolicy: dto.retryPolicy ?? { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 5000, maxDelayMs: 60000 }, concurrencyLimit: dto.concurrencyLimit ?? 1 }; + this.schedules.push(s); + return of(s).pipe(delay(300)); + } + + updateSchedule(id: string, dto: UpdateScheduleDto): Observable { + const idx = this.schedules.findIndex(s => s.id === id); + if (idx >= 0) { Object.assign(this.schedules[idx], dto, { updatedAt: new Date().toISOString() }); } + return of(this.schedules[idx] ?? this.schedules[0]).pipe(delay(300)); + } + + deleteSchedule(id: string): Observable { + this.schedules = this.schedules.filter(s => s.id !== id); + return of(void 0).pipe(delay(200)); + } + + pauseSchedule(id: string): Observable { + const s = this.schedules.find(s => s.id === id); + if (s) s.enabled = false; + return of(void 0).pipe(delay(200)); + } + + resumeSchedule(id: string): Observable { + const s = this.schedules.find(s => s.id === id); + if (s) s.enabled = true; + return of(void 0).pipe(delay(200)); + } + + triggerSchedule(_id: string): Observable { + return of(void 0).pipe(delay(200)); + } + + previewImpact(schedule: CreateScheduleDto): Observable { + return of({ + scheduleId: 'preview', + proposedChange: 'enable' as const, + affectedRuns: 0, + nextRunTime: new Date().toISOString(), + estimatedLoad: 0.15, + conflicts: [], + warnings: schedule.concurrencyLimit && schedule.concurrencyLimit > 3 ? ['High concurrency limit may impact other schedules'] : [], + }).pipe(delay(200)); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/score.client.ts b/src/Web/StellaOps.Web/src/app/core/api/score.client.ts index 692fd4026..82aef2eb8 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/score.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/score.client.ts @@ -197,7 +197,9 @@ export class HttpScoreClient implements ScoreApi { private readonly config = inject(AppConfigService); private get baseUrl(): string { - return `${this.config.apiBaseUrl}/api/v1/scores`; + const gatewayBase = this.config.config.apiBaseUrls.gateway ?? this.config.config.apiBaseUrls.authority; + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/scores`; } getScoreSummary(scanId: string): Observable { diff --git a/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts b/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts index fd4d8da60..bea8c55ad 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/security-findings.client.ts @@ -4,7 +4,8 @@ */ import { Injectable, InjectionToken, Inject } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { delay, map } from 'rxjs/operators'; import { AuthSessionStore } from '../auth/auth-session.store'; // ============================================================================ @@ -73,14 +74,16 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { if (filter?.environment) params = params.set('environment', filter.environment); if (filter?.limit) params = params.set('limit', filter.limit.toString()); if (filter?.sort) params = params.set('sort', filter.sort); - return this.http.get(`${this.baseUrl}/api/v1/findings`, { + return this.http.get(`${this.baseUrl}/api/v1/findings/summaries`, { params, headers: this.buildHeaders(), - }); + }).pipe( + map((res: any) => Array.isArray(res) ? res : (res?.items ?? [])), + ); } getFinding(findingId: string): Observable { - return this.http.get(`${this.baseUrl}/api/v1/findings/${findingId}`, { + return this.http.get(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, { headers: this.buildHeaders(), }); } @@ -94,3 +97,45 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi { return new HttpHeaders(headers); } } + +// ============================================================================ +// Mock Implementation +// ============================================================================ + +@Injectable({ providedIn: 'root' }) +export class MockSecurityFindingsClient implements SecurityFindingsApi { + listFindings(_filter?: FindingsFilter): Observable { + const data: FindingDto[] = [ + { id: 'f-1', package: 'lodash', version: '4.17.20', severity: 'CRITICAL', cvss: 9.8, reachable: true, reachabilityConfidence: 0.95, vexStatus: 'affected', releaseId: 'rel-1', releaseVersion: '1.2.3', delta: 'new', environments: ['prod', 'staging'], firstSeen: '2026-02-10T08:00:00Z' }, + { id: 'f-2', package: 'jackson-databind', version: '2.14.0', severity: 'HIGH', cvss: 7.5, reachable: true, reachabilityConfidence: 0.88, vexStatus: 'under_investigation', releaseId: 'rel-1', releaseVersion: '1.2.3', delta: 'unchanged', environments: ['prod'], firstSeen: '2026-02-08T10:00:00Z' }, + { id: 'f-3', package: 'express', version: '4.18.1', severity: 'MEDIUM', cvss: 5.3, reachable: false, reachabilityConfidence: 0.72, vexStatus: 'not_affected', releaseId: 'rel-2', releaseVersion: '2.0.0', delta: 'unchanged', environments: ['dev', 'staging'], firstSeen: '2026-01-28T12:00:00Z' }, + { id: 'f-4', package: 'netty', version: '4.1.86', severity: 'HIGH', cvss: 7.2, reachable: null, vexStatus: 'none', releaseId: 'rel-3', releaseVersion: '3.1.0', delta: 'new', environments: ['prod', 'staging', 'dev'], firstSeen: '2026-02-14T09:00:00Z' }, + { id: 'f-5', package: 'openssl', version: '3.0.8', severity: 'CRITICAL', cvss: 9.1, reachable: true, reachabilityConfidence: 0.99, vexStatus: 'affected', releaseId: 'rel-1', releaseVersion: '1.2.3', delta: 'unchanged', environments: ['prod'], firstSeen: '2026-02-01T06:00:00Z' }, + { id: 'f-6', package: 'spring-core', version: '6.0.4', severity: 'MEDIUM', cvss: 6.1, reachable: false, vexStatus: 'fixed', releaseId: 'rel-2', releaseVersion: '2.0.0', delta: 'resolved', environments: ['staging'], firstSeen: '2026-01-20T14:00:00Z' }, + { id: 'f-7', package: 'axios', version: '1.3.0', severity: 'LOW', cvss: 3.7, reachable: false, vexStatus: 'none', releaseId: 'rel-4', releaseVersion: '4.0.0-beta', delta: 'new', environments: ['dev'], firstSeen: '2026-02-15T11:00:00Z' }, + ]; + return of(data).pipe(delay(300)); + } + + getFinding(findingId: string): Observable { + return of({ + id: findingId, + package: 'lodash', + version: '4.17.20', + severity: 'CRITICAL' as const, + cvss: 9.8, + reachable: true, + reachabilityConfidence: 0.95, + vexStatus: 'affected', + releaseId: 'rel-1', + releaseVersion: '1.2.3', + delta: 'new', + environments: ['prod', 'staging'], + firstSeen: '2026-02-10T08:00:00Z', + description: 'Prototype Pollution in lodash allows attackers to manipulate JavaScript objects via crafted input.', + references: ['https://nvd.nist.gov/vuln/detail/CVE-2025-1234'], + affectedVersions: ['< 4.17.21'], + fixedVersions: ['4.17.21'], + }).pipe(delay(200)); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/security-overview.client.ts b/src/Web/StellaOps.Web/src/app/core/api/security-overview.client.ts index d26701754..71a4fbff1 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/security-overview.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/security-overview.client.ts @@ -5,7 +5,7 @@ import { Injectable, InjectionToken, Inject } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable, forkJoin, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, delay, map } from 'rxjs/operators'; import { AuthSessionStore } from '../auth/auth-session.store'; import { SECURITY_FINDINGS_API_BASE_URL } from './security-findings.client'; import { POLICY_EXCEPTIONS_API_BASE_URL } from './policy-exceptions.client'; @@ -84,13 +84,16 @@ export class SecurityOverviewHttpClient implements SecurityOverviewApi { getOverviewStats(): Observable { const headers = this.buildHeaders(); - const findings$ = this.http.get( - `${this.scannerBaseUrl}/api/v1/findings`, + const findings$ = this.http.get( + `${this.scannerBaseUrl}/api/v1/findings/summaries`, { headers } - ).pipe(catchError(() => of([] as any[]))); + ).pipe( + map((res: any) => Array.isArray(res) ? res : (res?.items ?? [])), + catchError(() => of([] as any[])), + ); const exceptions$ = this.http.get( - `${this.policyBaseUrl}/policyGateway/api/v1/policy/exception/requests`, + `${this.policyBaseUrl}/api/policy/exceptions`, { params: { status: 'active' }, headers } ).pipe(catchError(() => of([] as any[]))); @@ -165,3 +168,34 @@ export class SecurityOverviewHttpClient implements SecurityOverviewApi { return new HttpHeaders(headers); } } + +// ============================================================================ +// Mock Implementation +// ============================================================================ + +@Injectable({ providedIn: 'root' }) +export class MockSecurityOverviewClient implements SecurityOverviewApi { + getOverviewStats(): Observable { + return of({ + stats: { critical: 2, high: 5, medium: 8, low: 12, reachable: 4 }, + vexStats: { covered: 15, pending: 12 }, + recentFindings: [ + { id: 'f-1', package: 'lodash:4.17.20', severity: 'CRITICAL', reachable: true, time: '2026-02-15T08:00:00Z' }, + { id: 'f-2', package: 'jackson-databind:2.14.0', severity: 'HIGH', reachable: true, time: '2026-02-14T10:00:00Z' }, + { id: 'f-3', package: 'express:4.18.1', severity: 'MEDIUM', reachable: false, time: '2026-02-13T12:00:00Z' }, + { id: 'f-4', package: 'netty:4.1.86', severity: 'HIGH', reachable: false, time: '2026-02-12T09:00:00Z' }, + { id: 'f-5', package: 'openssl:3.0.8', severity: 'CRITICAL', reachable: true, time: '2026-02-11T06:00:00Z' }, + ], + topPackages: [ + { name: 'lodash', version: '4.17.20', critical: 1, high: 2, medium: 1 }, + { name: 'jackson-databind', version: '2.14.0', critical: 0, high: 3, medium: 2 }, + { name: 'netty', version: '4.1.86', critical: 1, high: 1, medium: 0 }, + { name: 'express', version: '4.18.1', critical: 0, high: 0, medium: 3 }, + ], + activeExceptions: [ + { id: 'ex-1', finding: 'CVE-2025-3456', reason: 'Mitigated by WAF rules', expiresIn: '14 days' }, + { id: 'ex-2', finding: 'CVE-2025-7890', reason: 'Not reachable in our deployment', expiresIn: '7 days' }, + ], + }).pipe(delay(300)); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/vex-consensus.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vex-consensus.client.ts index 3e36f48c2..8b55a8c28 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vex-consensus.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vex-consensus.client.ts @@ -377,11 +377,6 @@ export class VexConsensusHttpClient implements VexConsensusApi { if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId); if (ifNoneMatch) headers = headers.set('If-None-Match', ifNoneMatch); - const session = this.authStore.session(); - if (session?.tokens.accessToken) { - headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`); - } - return headers; } @@ -389,10 +384,7 @@ export class VexConsensusHttpClient implements VexConsensusApi { const tenant = tenantId?.trim() || this.tenantService.activeTenantId() || this.authStore.getActiveTenantId(); - if (!tenant) { - throw new Error('VexConsensusHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private cacheStatement(statement: VexConsensusStatement): void { diff --git a/src/Web/StellaOps.Web/src/app/core/api/vex-decisions.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vex-decisions.client.ts index dd7f84572..a601872b1 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vex-decisions.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vex-decisions.client.ts @@ -106,11 +106,6 @@ export class VexDecisionsHttpClient implements VexDecisionsApi { if (projectId) headers = headers.set('X-Stella-Project', projectId); if (ifNoneMatch) headers = headers.set('If-None-Match', ifNoneMatch); - const session = this.authSession.session(); - if (session?.tokens.accessToken) { - headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`); - } - return headers; } @@ -127,8 +122,7 @@ export class VexDecisionsHttpClient implements VexDecisionsApi { private resolveTenant(tenantId?: string): string { const tenant = tenantId ?? this.tenantService.activeTenantId(); - if (!tenant) throw new Error('VexDecisionsHttpClient requires an active tenant identifier.'); - return tenant; + return tenant ?? 'default'; } } diff --git a/src/Web/StellaOps.Web/src/app/core/api/vex-evidence.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vex-evidence.client.ts index b5b8d9c8a..7a3d62f5c 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vex-evidence.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vex-evidence.client.ts @@ -128,10 +128,7 @@ export class VexEvidenceHttpClient implements VexEvidenceApi { private resolveTenant(tenantId?: string): string { const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('VexEvidenceHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders { diff --git a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts index 8627ef01c..6a8493a63 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vex-hub.client.ts @@ -74,16 +74,16 @@ export class VexHubApiHttpClient implements VexHubApi { const headers = this.buildHeaders(traceId); let httpParams = new HttpParams(); - if (params.cveId) httpParams = httpParams.set('cveId', params.cveId); - if (params.product) httpParams = httpParams.set('product', params.product); + if (params.cveId) httpParams = httpParams.set('vulnerabilityId', params.cveId); + if (params.product) httpParams = httpParams.set('productKey', params.product); if (params.status) httpParams = httpParams.set('status', params.status); - if (params.source) httpParams = httpParams.set('source', params.source); + if (params.source) httpParams = httpParams.set('sourceId', params.source); if (params.dateFrom) httpParams = httpParams.set('dateFrom', params.dateFrom); if (params.dateTo) httpParams = httpParams.set('dateTo', params.dateTo); if (params.limit) httpParams = httpParams.set('limit', params.limit.toString()); if (params.offset) httpParams = httpParams.set('offset', params.offset.toString()); - return this.http.get(`${this.baseUrl}/statements`, { headers, params: httpParams }).pipe( + return this.http.get(`${this.baseUrl}/search`, { headers, params: httpParams }).pipe( catchError((err) => throwError(() => this.mapError(err, traceId))) ); } @@ -92,7 +92,7 @@ export class VexHubApiHttpClient implements VexHubApi { const traceId = options.traceId ?? generateTraceId(); const headers = this.buildHeaders(traceId); - return this.http.get(`${this.baseUrl}/statements/${encodeURIComponent(statementId)}`, { headers }).pipe( + return this.http.get(`${this.baseUrl}/statement/${encodeURIComponent(statementId)}`, { headers }).pipe( catchError((err) => throwError(() => this.mapError(err, traceId))) ); } @@ -119,7 +119,19 @@ export class VexHubApiHttpClient implements VexHubApi { const traceId = options.traceId ?? generateTraceId(); const headers = this.buildHeaders(traceId); - return this.http.get(`${this.baseUrl}/stats`, { headers }).pipe( + return this.http.get(`${this.baseUrl}/stats`, { headers }).pipe( + map((res: any) => ({ + totalStatements: res.totalStatements ?? 0, + byStatus: res.byStatus ?? { + affected: 0, + not_affected: 0, + fixed: 0, + under_investigation: 0, + }, + bySource: res.bySource ?? {}, + recentActivity: res.recentActivity ?? [], + trends: res.trends, + } as VexHubStats)), catchError((err) => throwError(() => this.mapError(err, traceId))) ); } @@ -202,6 +214,9 @@ export class VexHubApiHttpClient implements VexHubApi { } private mapError(err: unknown, traceId: string): Error { + if (err && typeof err === 'object' && 'status' in err && 'message' in err) { + return new Error(`[${traceId}] VEX Hub error: ${(err as any).status} ${(err as any).statusText ?? (err as any).message}`); + } if (err instanceof Error) { return new Error(`[${traceId}] VEX Hub error: ${err.message}`); } diff --git a/src/Web/StellaOps.Web/src/app/core/api/vuln-export-orchestrator.service.ts b/src/Web/StellaOps.Web/src/app/core/api/vuln-export-orchestrator.service.ts index 6744f58a7..9394a3f04 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vuln-export-orchestrator.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vuln-export-orchestrator.service.ts @@ -490,10 +490,7 @@ export class VulnExportOrchestratorService implements VulnExportOrchestratorApi const tenant = tenantId?.trim() || this.tenantService.activeTenantId() || this.authStore.getActiveTenantId(); - if (!tenant) { - throw new Error('VulnExportOrchestratorService requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private createError(code: string, message: string, traceId: string): Error { diff --git a/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts b/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts index bdad6ef25..4f9874761 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/vulnerability-http.client.ts @@ -368,18 +368,6 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId); if (requestId) headers = headers.set('X-Request-Id', requestId); - // Add anti-forgery token if available - const session = this.authSession.session(); - if (session?.tokens.accessToken) { - headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`); - } - - // Add DPoP proof if available (for proof-of-possession) - const dpopThumbprint = session?.dpopKeyThumbprint; - if (dpopThumbprint) { - headers = headers.set('X-DPoP-Thumbprint', dpopThumbprint); - } - return headers; } @@ -388,10 +376,7 @@ export class VulnerabilityHttpClient implements VulnerabilityApi { const tenant = (tenantId && tenantId.trim()) || this.tenantService.activeTenantId() || this.authSession.getActiveTenantId(); - if (!tenant) { - throw new Error('VulnerabilityHttpClient requires an active tenant identifier.'); - } - return tenant; + return tenant ?? 'default'; } private generateRequestId(): string { diff --git a/src/Web/StellaOps.Web/src/app/core/auth/auth-storage.service.ts b/src/Web/StellaOps.Web/src/app/core/auth/auth-storage.service.ts index 7017dc161..3ab3fdb94 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/auth-storage.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/auth-storage.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request'; +const LOGIN_REQUEST_PREFIX = 'stellaops.auth.login.'; export interface PendingLoginRequest { readonly state: string; @@ -18,7 +18,11 @@ export class AuthStorageService { if (typeof sessionStorage === 'undefined') { return; } - sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request)); + sessionStorage.setItem( + LOGIN_REQUEST_PREFIX + request.state, + JSON.stringify(request) + ); + this.pruneStaleEntries(); } consumePendingLogin(expectedState: string): PendingLoginRequest | null { @@ -26,12 +30,13 @@ export class AuthStorageService { return null; } - const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY); + const key = LOGIN_REQUEST_PREFIX + expectedState; + const raw = sessionStorage.getItem(key); if (!raw) { return null; } - sessionStorage.removeItem(LOGIN_REQUEST_KEY); + sessionStorage.removeItem(key); try { const request = JSON.parse(raw) as PendingLoginRequest; if (request.state !== expectedState) { @@ -42,4 +47,27 @@ export class AuthStorageService { return null; } } + + /** Remove entries older than 10 minutes to prevent sessionStorage bloat. */ + private pruneStaleEntries(): void { + try { + const cutoff = Date.now() - 10 * 60 * 1000; + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (!key?.startsWith(LOGIN_REQUEST_PREFIX)) continue; + const raw = sessionStorage.getItem(key); + if (!raw) continue; + try { + const entry = JSON.parse(raw) as PendingLoginRequest; + if (entry.createdAtEpochMs < cutoff) { + sessionStorage.removeItem(key); + } + } catch { + sessionStorage.removeItem(key); + } + } + } catch { + // Non-fatal + } + } } diff --git a/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts b/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts index be338a4ee..6cc44567e 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts @@ -128,6 +128,23 @@ export const StellaOpsScopes = { // Findings scope FINDINGS_READ: 'findings:read', + + // Notify scopes + NOTIFY_VIEWER: 'notify.viewer', + NOTIFY_OPERATOR: 'notify.operator', + NOTIFY_ADMIN: 'notify.admin', + + // Risk scopes + RISK_READ: 'risk:read', + + // Health scopes + HEALTH_READ: 'health:read', + + // Vulnerability scopes + VULN_VIEW: 'vuln:view', + VULN_INVESTIGATE: 'vuln:investigate', + VULN_OPERATE: 'vuln:operate', + VULN_AUDIT: 'vuln:audit', } as const; export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes]; @@ -334,6 +351,19 @@ export const ScopeLabels: Record = { 'exceptions:write': 'Create Exceptions', // Findings scope label 'findings:read': 'View Policy Findings', + // Notify scope labels + 'notify.viewer': 'View Notifications', + 'notify.operator': 'Operate Notifications', + 'notify.admin': 'Administer Notifications', + // Risk scope labels + 'risk:read': 'View Risk Profiles', + // Health scope labels + 'health:read': 'View Platform Health', + // Vulnerability scope labels + 'vuln:view': 'View Vulnerabilities', + 'vuln:investigate': 'Investigate Vulnerabilities', + 'vuln:operate': 'Operate Vulnerability Management', + 'vuln:audit': 'Audit Vulnerability Decisions', }; /** diff --git a/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts b/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts index 711215399..5793ef179 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/tenant-activation.service.ts @@ -26,6 +26,7 @@ export type TenantScope = | 'advisory-ai' | 'ledger' | 'exception' + | 'exceptions' | 'aoc' | 'sbom' | 'attest' @@ -268,6 +269,16 @@ export class TenantActivationService { } const grantedScopes = new Set(session.scopes); + + // When scopes are not populated in the session (e.g. not extracted from token), + // skip client-side scope checks and let the backend enforce authorization. + if (grantedScopes.size === 0) { + if (resource && action) { + this.emitDecision({ resource, action, requiredScopes, decision: 'allow' }); + } + return { allowed: true, missingScopes: [] }; + } + const missingScopes = requiredScopes.filter(scope => !this.scopeMatches(scope, grantedScopes)); if (missingScopes.length > 0) { diff --git a/src/Web/StellaOps.Web/src/app/core/auth/tenant-http.interceptor.ts b/src/Web/StellaOps.Web/src/app/core/auth/tenant-http.interceptor.ts index b1358d1b7..1fe5f4eb3 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/tenant-http.interceptor.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/tenant-http.interceptor.ts @@ -11,6 +11,7 @@ import { AuthSessionStore } from './auth-session.store'; */ export const TENANT_HEADERS = { TENANT_ID: 'X-Tenant-Id', + STELLAOPS_TENANT: 'X-StellaOps-Tenant', PROJECT_ID: 'X-Project-Id', TRACE_ID: 'X-Stella-Trace-Id', REQUEST_ID: 'X-Request-Id', @@ -67,11 +68,10 @@ export class TenantHttpInterceptor implements HttpInterceptor { private addTenantHeaders(request: HttpRequest): HttpRequest { const headers: Record = {}; - // Add tenant ID - const tenantId = this.getTenantId(); - if (tenantId) { - headers[TENANT_HEADERS.TENANT_ID] = tenantId; - } + // Add tenant ID (use "default" if no active tenant) + const tenantId = this.getTenantId() ?? 'default'; + headers[TENANT_HEADERS.TENANT_ID] = tenantId; + headers[TENANT_HEADERS.STELLAOPS_TENANT] = tenantId; // Add project ID if active const projectId = this.tenantService.activeProjectId(); diff --git a/src/Web/StellaOps.Web/src/app/core/config/app-config.service.ts b/src/Web/StellaOps.Web/src/app/core/config/app-config.service.ts index 31a8fb961..bb6d2ff87 100644 --- a/src/Web/StellaOps.Web/src/app/core/config/app-config.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/config/app-config.service.ts @@ -268,6 +268,7 @@ export class AppConfigService { private normalizeConfig(config: AppConfig): AppConfig { const authority = { ...config.authority, + ...this.normalizeAuthorityUrls(config.authority), dpopAlgorithms: config.authority.dpopAlgorithms?.length ?? 0 ? config.authority.dpopAlgorithms @@ -301,6 +302,42 @@ export class AppConfigService { }; } + /** + * Converts absolute Docker-internal authority URLs to relative paths so the + * OIDC flow stays on the same origin (the gateway) instead of redirecting + * to unreachable internal hostnames like https://stella-ops.local. + * + * The gateway proxies /connect/* to the authority service. + */ + private normalizeAuthorityUrls(auth: AuthorityConfig): Partial { + const overrides: Record = {}; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + if (!origin) return overrides; + + const urlFields: (keyof AuthorityConfig)[] = [ + 'issuer', 'authorizeEndpoint', 'tokenEndpoint', 'logoutEndpoint', + 'redirectUri', 'silentRefreshRedirectUri', 'postLogoutRedirectUri', + ]; + + for (const field of urlFields) { + const value = auth[field]; + if (typeof value !== 'string') continue; + + try { + const parsed = new URL(value); + if (parsed.hostname === 'stella-ops.local' || parsed.hostname.endsWith('.stella-ops.local')) { + // Rewrite to current origin so the OIDC flow stays on the gateway. + // The URL constructor requires an absolute base for proper URL building. + overrides[field] = origin + parsed.pathname + parsed.search + parsed.hash; + } + } catch { + // Not an absolute URL, leave as-is + } + } + + return overrides; + } + /** * Converts absolute Docker-internal URLs (e.g. http://scanner.stella-ops.local) * to relative paths (e.g. /scanner) so requests go through the gateway's @@ -320,6 +357,22 @@ export class AppConfigService { normalized[key] = value; } } + + // The Platform service uses distinct key names (policyGateway, policyEngine, + // findingsLedger, vexhub) while frontend clients expect canonical keys + // (policy, ledger, vex). When a canonical key is missing, default to empty + // string so the gateway's path-based routing dispatches to the correct + // backend service automatically. + if (!normalized['policy'] && (normalized['policyGateway'] || normalized['policyEngine'])) { + normalized['policy'] = ''; + } + if (!normalized['ledger'] && normalized['findingsLedger']) { + normalized['ledger'] = ''; + } + if (!normalized['vex'] && normalized['vexhub']) { + normalized['vex'] = ''; + } + return normalized as unknown as ApiBaseUrlConfig; } } diff --git a/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts b/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts index f6b12a178..e129c6e5a 100644 --- a/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts @@ -55,32 +55,61 @@ export class BackendProbeService { try { const authorityBase = this.configService.config.authority.issuer; - // Relative issuer (e.g. "/authority") means config came from the static - // fallback — there is no real backend to probe yet. + // Relative issuer after normalization (e.g. "/" from a Docker-internal + // URL) is valid — the gateway proxies /.well-known to the authority. + // Only the static fallback "/authority" indicates no real backend. if (authorityBase.startsWith('/')) { - this.probeStatus.set('unreachable'); - this.probeError.set('Authority issuer is a relative path; no backend configured.'); + // Probe same-origin well-known endpoint through the gateway + const body = await firstValueFrom( + this.http + .get('/.well-known/openid-configuration', { responseType: 'text', withCredentials: false }) + .pipe(timeout(PROBE_TIMEOUT_MS)) + ); + let parsed: Record; + try { + parsed = JSON.parse(body as string); + } catch { + this.probeStatus.set('unreachable'); + this.probeError.set('Authority returned non-JSON response (likely SPA fallback).'); + return; + } + if (!parsed['issuer'] || !parsed['authorization_endpoint']) { + this.probeStatus.set('unreachable'); + this.probeError.set('Authority response is not a valid OIDC discovery document.'); + return; + } + this.probeStatus.set('reachable'); return; } - let normalized = authorityBase.endsWith('/') - ? authorityBase.slice(0, -1) - : authorityBase; + // When the issuer is a Docker-internal URL (e.g. https://stella-ops.local + // or https://authority.stella-ops.local), it is not reachable from the + // browser. The gateway proxies /.well-known to the authority service, + // so probe via same-origin instead. + let wellKnownUrl: string; + try { + const issuerHost = new URL(authorityBase).hostname; + if (issuerHost === 'stella-ops.local' || issuerHost.endsWith('.stella-ops.local')) { + wellKnownUrl = '/.well-known/openid-configuration'; + } else { + let normalized = authorityBase.endsWith('/') + ? authorityBase.slice(0, -1) + : authorityBase; - // Upgrade http → https when the SPA itself was loaded over HTTPS. - // This prevents mixed-content blocks when envsettings.json specifies - // http:// URLs but the browser enforces HTTPS-only fetch from an - // HTTPS origin. - if ( - typeof window !== 'undefined' && - window.location.protocol === 'https:' && - normalized.startsWith('http://') - ) { - normalized = normalized.replace(/^http:\/\//, 'https://'); + if ( + typeof window !== 'undefined' && + window.location.protocol === 'https:' && + normalized.startsWith('http://') + ) { + normalized = normalized.replace(/^http:\/\//, 'https://'); + } + + wellKnownUrl = `${normalized}/.well-known/openid-configuration`; + } + } catch { + wellKnownUrl = '/.well-known/openid-configuration'; } - const wellKnownUrl = `${normalized}/.well-known/openid-configuration`; - const body = await firstValueFrom( this.http .get(wellKnownUrl, { responseType: 'text', withCredentials: false }) diff --git a/src/Web/StellaOps.Web/src/app/core/services/fix-verification.service.ts b/src/Web/StellaOps.Web/src/app/core/services/fix-verification.service.ts index 3e83bac08..0dfad9730 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/fix-verification.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/fix-verification.service.ts @@ -176,13 +176,13 @@ export class MockFixVerificationApi implements FixVerificationApi { { functionName: 'vulnerable_func', status: verdict === 'fixed' ? 'modified' : verdict === 'partial' ? 'partially_modified' : 'unchanged', - statusIcon: verdict === 'fixed' ? '✓' : verdict === 'partial' ? '◐' : '✗', + statusIcon: verdict === 'fixed' ? '' : verdict === 'partial' ? '' : '', details: verdict === 'fixed' ? 'Bounds check inserted' : verdict === 'partial' ? 'Some paths still reachable' : 'No changes detected', children: [ { name: 'bb7→bb9', status: verdict === 'fixed' ? 'eliminated' : 'present', - statusIcon: verdict === 'fixed' ? '✗' : '○', + statusIcon: verdict === 'fixed' ? '' : '', details: verdict === 'fixed' ? 'Edge removed in patch' : 'Edge still present' } ] diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts index 9e7417d93..6b2b5d47b 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts @@ -422,7 +422,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid } .tab.active { color: var(--color-status-info-text); border-bottom-color: var(--color-status-info-text); font-weight: var(--font-weight-semibold); } - .tab-content { background: white; border-radius: var(--radius-lg); } + .tab-content { background: var(--color-surface-primary); border-radius: var(--radius-lg); } .tab-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .tab-header h2 { margin: 0; font-size: 1.25rem; } @@ -488,7 +488,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid .config-section { background: var(--color-surface-primary); padding: 1.5rem; border-radius: var(--radius-lg); } .config-section h3 { margin: 0 0 0.5rem; font-size: 1rem; } .section-desc { color: var(--color-text-secondary); font-size: 0.875rem; margin: 0 0 1rem; } - .config-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: white; border-radius: var(--radius-sm); margin-bottom: 0.5rem; } + .config-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: var(--color-surface-secondary); border-radius: var(--radius-sm); margin-bottom: 0.5rem; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts index ce77e44b3..ca6614d87 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/channel-management.component.ts @@ -382,7 +382,7 @@ interface ChannelTypeOption { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); - background: white; + background: var(--color-surface-primary); } .channel-grid { @@ -393,7 +393,7 @@ interface ChannelTypeOption { .channel-card { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); transition: box-shadow 0.2s; @@ -565,7 +565,7 @@ interface ChannelTypeOption { flex-direction: column; align-items: center; padding: 1rem; - background: white; + background: var(--color-surface-secondary); border: 2px solid var(--color-border-primary); border-radius: var(--radius-lg); cursor: pointer; @@ -652,7 +652,7 @@ interface ChannelTypeOption { } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .loading-state, .empty-state { display: flex; diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts index dac07f93e..bd0ca18e3 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-analytics.component.ts @@ -265,7 +265,7 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif } .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .loading-state { padding: 3rem; text-align: center; color: var(--color-text-secondary); } @@ -279,7 +279,7 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif .metric-card { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } @@ -360,7 +360,7 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif /* Analytics Sections */ .analytics-section { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); margin-bottom: 1rem; @@ -501,7 +501,7 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif flex-direction: column; align-items: center; padding: 1rem; - background: white; + background: var(--color-surface-secondary); border-radius: var(--radius-lg); border: 2px solid; } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-history.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-history.component.ts index dbf4bcd0b..efdd27660 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-history.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/delivery-history.component.ts @@ -380,7 +380,7 @@ import { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); - background: white; + background: var(--color-surface-primary); min-width: 150px; } @@ -392,7 +392,7 @@ import { } .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .table-container { overflow-x: auto; @@ -567,7 +567,7 @@ import { } .modal-content { - background: white; + background: var(--color-surface-primary); border-radius: var(--radius-lg); width: 90%; max-width: 700px; @@ -667,7 +667,7 @@ import { .attempt-item { padding: 0.75rem; - background: white; + background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/escalation-config.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/escalation-config.component.ts index c8d68a968..e0c416950 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/escalation-config.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/escalation-config.component.ts @@ -252,7 +252,7 @@ import { .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; } .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; } .btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; } .btn-icon.btn-danger { color: var(--color-status-error); } @@ -261,7 +261,7 @@ import { .policy-card { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } @@ -390,7 +390,7 @@ import { .level-form { padding: 1rem; - background: white; + background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); margin-bottom: 1rem; @@ -443,7 +443,7 @@ import { flex-wrap: wrap; gap: 0.5rem; padding: 1rem; - background: white; + background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts index 93d1c811d..8eb90ef6f 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts @@ -210,7 +210,7 @@ interface ConfigSubTab { align-items: center; gap: 0.75rem; padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); transition: box-shadow 0.2s, transform 0.2s; @@ -323,12 +323,12 @@ interface ConfigSubTab { } .sub-tab-button:hover { - background: white; + background: var(--color-surface-primary); color: var(--color-status-info-text); } .sub-tab-button.active { - background: white; + background: var(--color-surface-primary); color: var(--color-status-info-text); border-color: var(--color-border-primary); box-shadow: var(--shadow-sm); @@ -336,7 +336,7 @@ interface ConfigSubTab { /* Tab Content */ .tab-content { - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); min-height: 400px; @@ -399,7 +399,7 @@ interface ConfigSubTab { } .btn-secondary { - background: white; + background: var(--color-surface-primary); color: var(--color-text-primary); border-color: var(--color-border-secondary); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-preview.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-preview.component.ts index f36e57d55..7aee2c302 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-preview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-preview.component.ts @@ -118,7 +118,7 @@ import { NotifierPreviewResponse, NotifierChannelType } from '../../../core/api/ `, styles: [` .notification-preview { - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; @@ -292,7 +292,7 @@ import { NotifierPreviewResponse, NotifierChannelType } from '../../../core/api/ .teams-card { display: flex; - background: white; + background: var(--color-surface-secondary); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; @@ -369,7 +369,7 @@ import { NotifierPreviewResponse, NotifierChannelType } from '../../../core/api/ display: flex; justify-content: space-between; padding: 0.5rem; - background: white; + background: var(--color-surface-secondary); border-radius: var(--radius-sm); border: 1px solid var(--color-border-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-editor.component.ts index 82b66be2f..5ac68d85f 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-editor.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-editor.component.ts @@ -378,7 +378,7 @@ import { .action-card { padding: 1rem; margin-bottom: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); } @@ -420,7 +420,7 @@ import { } .btn-secondary { - background: white; + background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts index 35eef58bb..700e683be 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-rule-list.component.ts @@ -219,7 +219,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); font-size: 0.875rem; - background: white; + background: var(--color-surface-primary); } .btn { @@ -238,7 +238,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor } .btn-secondary { - background: white; + background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override-management.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override-management.component.ts index a2c1b7f5a..43503f54f 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override-management.component.ts @@ -281,7 +281,7 @@ import { .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; } .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; } .btn-icon.btn-danger { color: var(--color-status-error); } @@ -289,7 +289,7 @@ import { .override-card { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); transition: all 0.2s; @@ -395,7 +395,7 @@ import { .preset-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; } .preset-btn { padding: 0.375rem 0.75rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-secondary); border-radius: var(--radius-sm); font-size: 0.75rem; diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override.component.ts index 6d8a8f536..5820c6165 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/operator-override.component.ts @@ -328,7 +328,7 @@ import { .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; } .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; } .btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; } .btn-icon.btn-danger { color: var(--color-status-error); } @@ -377,7 +377,7 @@ import { .override-card { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); transition: border-color 0.15s; diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/quiet-hours-config.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/quiet-hours-config.component.ts index 1563e6967..adba2c271 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/quiet-hours-config.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/quiet-hours-config.component.ts @@ -232,7 +232,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; } .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; } .btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; } .btn-icon.btn-danger { color: var(--color-status-error); } @@ -241,7 +241,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr .config-card { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } @@ -315,7 +315,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr .day-checkbox { display: flex; align-items: center; gap: 0.25rem; font-size: 0.75rem; cursor: pointer; } .day-checkbox input { width: 14px; height: 14px; } - .window-form, .exemption-form { padding: 0.75rem; background: white; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); margin-bottom: 0.5rem; } + .window-form, .exemption-form { padding: 0.75rem; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); margin-bottom: 0.5rem; } .form-footer { display: flex; justify-content: flex-end; gap: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts index 762b1e597..8ec5782f1 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/rule-simulator.component.ts @@ -342,7 +342,7 @@ import { .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; } .quick-templates { @@ -365,7 +365,7 @@ import { .template-btn { padding: 0.375rem 0.75rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-secondary); border-radius: var(--radius-sm); font-size: 0.75rem; @@ -380,7 +380,7 @@ import { /* Results Panel */ .result-card { - background: white; + background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/template-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/template-editor.component.ts index 62edd710a..3b23054c5 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/template-editor.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/template-editor.component.ts @@ -347,7 +347,7 @@ import { .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; } .btn-icon { @@ -424,7 +424,7 @@ import { } .preview-result { - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 1rem; diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/throttle-config.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/throttle-config.component.ts index 4388fb55c..9a4fecbaf 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/throttle-config.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/throttle-config.component.ts @@ -296,7 +296,7 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event'; .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; } .btn-primary { background: var(--color-status-info-text); color: var(--color-text-heading); border: none; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } - .btn-secondary { background: white; color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } + .btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); } .btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; } .btn-icon.btn-danger { color: var(--color-status-error); } @@ -309,7 +309,7 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event'; .throttle-card { padding: 1rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } @@ -434,7 +434,7 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event'; flex-direction: column; align-items: center; padding: 0.75rem; - background: white; + background: var(--color-surface-secondary); border-radius: var(--radius-md); } @@ -466,7 +466,7 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event'; .preset-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; } .preset-btn { padding: 0.375rem 0.75rem; - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-secondary); border-radius: var(--radius-sm); font-size: 0.75rem; diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts index b07916158..488b40310 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts @@ -445,7 +445,7 @@ export interface GateContext { border-radius: var(--radius-md); font-size: 0.875rem; color: var(--color-text-primary); - background: white; + background: var(--color-surface-primary); transition: border-color 0.15s ease, box-shadow 0.15s ease; } diff --git a/src/Web/StellaOps.Web/src/app/features/auth/auth-callback.component.ts b/src/Web/StellaOps.Web/src/app/features/auth/auth-callback.component.ts index 868ff0c54..85b0c1125 100644 --- a/src/Web/StellaOps.Web/src/app/features/auth/auth-callback.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/auth/auth-callback.component.ts @@ -253,7 +253,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service'; margin: 0 0 0.375rem; font-size: 1.125rem; font-weight: 600; - color: #F5F0E6; + color: var(--color-text-inverse); line-height: 1.3; animation: slide-up 500ms cubic-bezier(0.18, 0.89, 0.32, 1) 200ms both; } @@ -279,7 +279,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service'; margin-bottom: 1.25rem; border-radius: 50%; background: rgba(239, 68, 68, 0.12); - color: #f87171; + color: var(--color-border-error); animation: fade-in 500ms ease both; } @@ -287,7 +287,7 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service'; margin: 0 0 0.5rem; font-size: 1.125rem; font-weight: 600; - color: #F5F0E6; + color: var(--color-text-inverse); line-height: 1.3; animation: slide-up 500ms ease 100ms both; } diff --git a/src/Web/StellaOps.Web/src/app/features/binary-index/patch-map.component.ts b/src/Web/StellaOps.Web/src/app/features/binary-index/patch-map.component.ts index 03a81713b..33d8712b7 100644 --- a/src/Web/StellaOps.Web/src/app/features/binary-index/patch-map.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/binary-index/patch-map.component.ts @@ -412,7 +412,7 @@ type ViewMode = 'heatmap' | 'details' | 'matches'; padding: 8px 16px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: white; + background: var(--color-surface-primary); font-size: var(--font-size-base); cursor: pointer; transition: all 0.15s; @@ -542,7 +542,7 @@ type ViewMode = 'heatmap' | 'details' | 'matches'; padding: 12px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - background: white; + background: var(--color-surface-primary); cursor: pointer; text-align: left; transition: all 0.15s; @@ -634,7 +634,7 @@ type ViewMode = 'heatmap' | 'details' | 'matches'; padding: 8px 16px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: white; + background: var(--color-surface-primary); cursor: pointer; } @@ -650,7 +650,7 @@ type ViewMode = 'heatmap' | 'details' | 'matches'; /* Details section */ .details-section { - background: white; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-xl); padding: 24px; @@ -748,7 +748,7 @@ type ViewMode = 'heatmap' | 'details' | 'matches'; font-size: var(--font-size-sm); border: 1px solid var(--color-status-info); border-radius: var(--radius-sm); - background: white; + background: var(--color-surface-primary); color: var(--color-status-info); cursor: pointer; } diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts index e3b2d0273..0991d3f23 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts @@ -56,7 +56,7 @@ export type UserRole = 'developer' | 'security' | 'audit'; @if (error()) { } diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/trust-indicators.component.ts b/src/Web/StellaOps.Web/src/app/features/compare/components/trust-indicators.component.ts index 37a30feb0..3f1535f9d 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/trust-indicators.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/trust-indicators.component.ts @@ -29,7 +29,7 @@ import { ScanDigest } from '../services/compare.service';
Signature
- {{ signatureIcon() }} + {{ signatureText() }}
@@ -144,11 +144,12 @@ export class TrustIndicatorsComponent { readonly signatureClass = computed(() => this.current()?.signatureStatus ?? 'unknown'); readonly signatureIcon = computed(() => { + const svg = (d: string) => ``; switch (this.current()?.signatureStatus) { - case 'valid': return '✓'; - case 'invalid': return '✗'; - case 'missing': return '?'; - default: return '—'; + case 'valid': return svg(''); + case 'invalid': return svg(''); + case 'missing': return svg(''); + default: return svg(''); } }); diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts index 3ff5906a7..d6bbb3904 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts @@ -172,7 +172,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';