save changes

This commit is contained in:
master
2026-02-17 00:51:35 +02:00
parent 70fdbfcf25
commit fb46a927ad
324 changed files with 4976 additions and 1499 deletions

View File

@@ -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:

View File

@@ -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`

View File

@@ -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/`.

View File

@@ -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.

View File

@@ -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`

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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*

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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;
/// <summary>
/// Source file pair used to compute semantic edit scripts.
/// </summary>
public sealed record SourceFileDiff
{
/// <summary>
/// Source file path (repository-relative).
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Previous file content.
/// </summary>
public string? BeforeContent { get; init; }
/// <summary>
/// Current file content.
/// </summary>
public string? AfterContent { get; init; }
}
/// <summary>
/// Source line span.
/// </summary>
public sealed record SourceSpan
{
/// <summary>
/// 1-based start line.
/// </summary>
public required int StartLine { get; init; }
/// <summary>
/// 1-based end line.
/// </summary>
public required int EndLine { get; init; }
}
/// <summary>
/// Deterministic semantic edit script.
/// </summary>
public sealed record SemanticEditScript
{
/// <summary>
/// Schema version.
/// </summary>
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Digest of the full source tree diff input.
/// </summary>
public required string SourceTreeDigest { get; init; }
/// <summary>
/// Deterministic ordered edits.
/// </summary>
public required IReadOnlyList<SemanticEdit> Edits { get; init; }
}
/// <summary>
/// Single semantic edit entry.
/// </summary>
public sealed record SemanticEdit
{
/// <summary>
/// Stable digest-derived ID for dedupe and audit references.
/// </summary>
public required string StableId { get; init; }
/// <summary>
/// Edit type: add, remove, move, update, rename.
/// </summary>
public required string EditType { get; init; }
/// <summary>
/// Node kind: file, method, class, field, import, statement.
/// </summary>
public required string NodeKind { get; init; }
/// <summary>
/// Deterministic node path.
/// </summary>
public required string NodePath { get; init; }
/// <summary>
/// Symbol anchor used to link to binary symbols.
/// </summary>
public required string Anchor { get; init; }
/// <summary>
/// Pre-change source span.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SourceSpan? PreSpan { get; init; }
/// <summary>
/// Post-change source span.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SourceSpan? PostSpan { get; init; }
/// <summary>
/// Pre-change digest.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PreDigest { get; init; }
/// <summary>
/// Post-change digest.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PostDigest { get; init; }
}
/// <summary>
/// Build-stage symbol map linked to build identity.
/// </summary>
public sealed record SymbolMap
{
/// <summary>
/// Schema version.
/// </summary>
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Build ID (ELF build-id, PDB GUID, or equivalent).
/// </summary>
public required string BuildId { get; init; }
/// <summary>
/// Optional binary digest for traceability.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? BinaryDigest { get; init; }
/// <summary>
/// Address derivation source (manifest, synthetic-signature).
/// </summary>
public string AddressSource { get; init; } = "manifest";
/// <summary>
/// Ordered symbol entries.
/// </summary>
public required IReadOnlyList<SymbolMapEntry> Symbols { get; init; }
}
/// <summary>
/// Symbol map entry.
/// </summary>
public sealed record SymbolMapEntry
{
/// <summary>
/// Symbol name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Symbol kind: function, object, section.
/// </summary>
public string Kind { get; init; } = "function";
/// <summary>
/// Start address.
/// </summary>
public required ulong AddressStart { get; init; }
/// <summary>
/// End address.
/// </summary>
public required ulong AddressEnd { get; init; }
/// <summary>
/// Containing section.
/// </summary>
public string Section { get; init; } = ".text";
/// <summary>
/// Source ranges for this symbol.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<SourceRange>? SourceRanges { get; init; }
/// <summary>
/// Symbol size derived from address range.
/// </summary>
[JsonIgnore]
public long Size => checked((long)(AddressEnd >= AddressStart ? AddressEnd - AddressStart + 1UL : 0UL));
}
/// <summary>
/// Source range metadata.
/// </summary>
public sealed record SourceRange
{
/// <summary>
/// Source file path.
/// </summary>
public required string File { get; init; }
/// <summary>
/// 1-based start line.
/// </summary>
public required int LineStart { get; init; }
/// <summary>
/// 1-based end line.
/// </summary>
public required int LineEnd { get; init; }
}
/// <summary>
/// Join artifact linking semantic edits to symbol changes.
/// </summary>
public sealed record SymbolPatchPlan
{
/// <summary>
/// Schema version.
/// </summary>
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Build ID before patch.
/// </summary>
public required string BuildIdBefore { get; init; }
/// <summary>
/// Build ID after patch.
/// </summary>
public required string BuildIdAfter { get; init; }
/// <summary>
/// Semantic script digest.
/// </summary>
public required string EditsDigest { get; init; }
/// <summary>
/// Old symbol map digest.
/// </summary>
public required string SymbolMapDigestBefore { get; init; }
/// <summary>
/// New symbol map digest.
/// </summary>
public required string SymbolMapDigestAfter { get; init; }
/// <summary>
/// Ordered symbol changes.
/// </summary>
public required IReadOnlyList<SymbolPatchChange> Changes { get; init; }
}
/// <summary>
/// Single symbol patch plan entry.
/// </summary>
public sealed record SymbolPatchChange
{
/// <summary>
/// Symbol name.
/// </summary>
public required string Symbol { get; init; }
/// <summary>
/// Change type: added, removed, modified, moved.
/// </summary>
public required string ChangeType { get; init; }
/// <summary>
/// Linked source anchors.
/// </summary>
public required IReadOnlyList<string> AstAnchors { get; init; }
/// <summary>
/// Hash before.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PreHash { get; init; }
/// <summary>
/// Hash after.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? PostHash { get; init; }
/// <summary>
/// Delta reference digest.
/// </summary>
public required string DeltaRef { get; init; }
}
/// <summary>
/// Normalized patch manifest for per-symbol deltas.
/// </summary>
public sealed record PatchManifest
{
/// <summary>
/// Schema version.
/// </summary>
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Build ID.
/// </summary>
public required string BuildId { get; init; }
/// <summary>
/// Normalization recipe identifier.
/// </summary>
public required string NormalizationRecipeId { get; init; }
/// <summary>
/// Ordered patch entries.
/// </summary>
public required IReadOnlyList<SymbolPatchArtifact> Patches { get; init; }
/// <summary>
/// Total delta bytes across symbols.
/// </summary>
[JsonIgnore]
public long TotalDeltaBytes => Patches.Sum(p => p.DeltaSizeBytes);
}
/// <summary>
/// Per-symbol patch artifact.
/// </summary>
public sealed record SymbolPatchArtifact
{
/// <summary>
/// Symbol name.
/// </summary>
public required string Symbol { get; init; }
/// <summary>
/// Address range in hex format.
/// </summary>
public required string AddressRange { get; init; }
/// <summary>
/// Digest of patch payload.
/// </summary>
public required string DeltaDigest { get; init; }
/// <summary>
/// Pre-patch metrics.
/// </summary>
public required PatchSizeHash Pre { get; init; }
/// <summary>
/// Post-patch metrics.
/// </summary>
public required PatchSizeHash Post { get; init; }
/// <summary>
/// Absolute byte delta.
/// </summary>
[JsonIgnore]
public long DeltaSizeBytes => Math.Abs(Post.Size - Pre.Size);
}
/// <summary>
/// Size/hash tuple.
/// </summary>
public sealed record PatchSizeHash
{
/// <summary>
/// Size in bytes.
/// </summary>
public required long Size { get; init; }
/// <summary>
/// Hash digest.
/// </summary>
public required string Hash { get; init; }
}
/// <summary>
/// Full hybrid diff evidence bundle.
/// </summary>
public sealed record HybridDiffEvidence
{
/// <summary>
/// Semantic edit script.
/// </summary>
public required SemanticEditScript SemanticEditScript { get; init; }
/// <summary>
/// Old symbol map.
/// </summary>
public required SymbolMap OldSymbolMap { get; init; }
/// <summary>
/// New symbol map.
/// </summary>
public required SymbolMap NewSymbolMap { get; init; }
/// <summary>
/// Symbol patch plan.
/// </summary>
public required SymbolPatchPlan SymbolPatchPlan { get; init; }
/// <summary>
/// Normalized patch manifest.
/// </summary>
public required PatchManifest PatchManifest { get; init; }
/// <summary>
/// Semantic edit script digest.
/// </summary>
public required string SemanticEditScriptDigest { get; init; }
/// <summary>
/// Old symbol map digest.
/// </summary>
public required string OldSymbolMapDigest { get; init; }
/// <summary>
/// New symbol map digest.
/// </summary>
public required string NewSymbolMapDigest { get; init; }
/// <summary>
/// Symbol patch plan digest.
/// </summary>
public required string SymbolPatchPlanDigest { get; init; }
/// <summary>
/// Patch manifest digest.
/// </summary>
public required string PatchManifestDigest { get; init; }
}

View File

@@ -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();

View File

@@ -23,7 +23,7 @@ public static class NotifyPersistenceExtensions
IConfiguration configuration,
string sectionName = "Postgres:Notify")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
services.AddSingleton<NotifyDataSource>();
// Register repositories

View File

@@ -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<AnalyticsVulnerabilityTrendPoint>();
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<AnalyticsComponentTrendPoint>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -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 =>

View File

@@ -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<PostgresOptions>) resolves the connection string.
builder.Services.Configure<StellaOps.Infrastructure.Postgres.Options.PostgresOptions>(
builder.Configuration.GetSection("Postgres:Policy"));
builder.Services.AddMemoryCache();
// Exception services
@@ -198,6 +201,20 @@ builder.Services.AddSingleton<IToolAccessEvaluator, ToolAccessEvaluator>();
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<PolicyGatewayMetrics>();
builder.Services.AddSingleton<PolicyGatewayDpopProofGenerator>();
builder.Services.AddSingleton<PolicyEngineTokenProvider>();

View File

@@ -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;
}
/// <summary>
/// Expands coarse OIDC scopes into fine-grained service scopes.
/// Pattern: "{service}:{action}" expands to "{service}.{resource}.{action}" for known resources.
/// </summary>
private static void ExpandCoarseScopes(HashSet<string> 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).
/// </summary>
public bool AllowScopeHeaderOverride { get; set; } = false;
/// <summary>
/// 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).
/// </summary>
public List<string> JwtPassthroughPrefixes { get; set; } = [];
}

View File

@@ -124,7 +124,11 @@ builder.Services.AddSingleton<IDpopProofValidator, DpopProofValidator>();
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 =>

View File

@@ -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" }

View File

@@ -22,4 +22,11 @@ public sealed class StellaOpsRoute
public string? TranslatesTo { get; set; }
public Dictionary<string, string> Headers { get; set; } = new();
/// <summary>
/// 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).
/// </summary>
public bool PreserveAuthHeaders { get; set; }
}

View File

@@ -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}'.");
}
}

View File

@@ -204,6 +204,42 @@ public sealed record ObservedCallPath
public IReadOnlyList<ulong?>? BinaryOffsets { get; init; }
}
/// <summary>
/// Metadata describing which BTF source was selected for probe loading.
/// </summary>
public sealed record RuntimeBtfSelection
{
/// <summary>
/// Selected BTF source kind (kernel, external-vmlinux, split-btf, unsupported, unavailable).
/// </summary>
public required string SourceKind { get; init; }
/// <summary>
/// Absolute path of the selected BTF source.
/// </summary>
public string? SourcePath { get; init; }
/// <summary>
/// SHA-256 digest of the selected BTF source.
/// </summary>
public string? SourceDigest { get; init; }
/// <summary>
/// Deterministic reason for the selected source.
/// </summary>
public required string SelectionReason { get; init; }
/// <summary>
/// Kernel release used for BTF lookup.
/// </summary>
public required string KernelRelease { get; init; }
/// <summary>
/// Kernel architecture used for BTF lookup.
/// </summary>
public required string KernelArch { get; init; }
}
/// <summary>
/// Summary of runtime signals collected for a container.
/// </summary>
@@ -265,6 +301,11 @@ public sealed record RuntimeSignalSummary
/// Combined hash of all observed paths for summary-level identity.
/// </summary>
public string? CombinedPathHash { get; init; }
/// <summary>
/// BTF source metadata selected for this runtime collection.
/// </summary>
public RuntimeBtfSelection? BtfSelection { get; init; }
}
/// <summary>

View File

@@ -33,6 +33,7 @@
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{
"glob": "config.json",
"input": "src/config",

View File

@@ -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();
})();

View File

@@ -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();
})();

View File

@@ -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
}
}

View File

@@ -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();
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -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 },
],
};

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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<PlatformListResponse<AnalyticsSupplierConcentration>> {
return of(this.wrap<AnalyticsSupplierConcentration>([
{ 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<PlatformListResponse<AnalyticsLicenseDistribution>> {
return of(this.wrap<AnalyticsLicenseDistribution>([
{ 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<PlatformListResponse<AnalyticsVulnerabilityExposure>> {
return of(this.wrap<AnalyticsVulnerabilityExposure>([
{ 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<PlatformListResponse<AnalyticsFixableBacklogItem>> {
return of(this.wrap<AnalyticsFixableBacklogItem>([
{ 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<PlatformListResponse<AnalyticsAttestationCoverage>> {
return of(this.wrap<AnalyticsAttestationCoverage>([
{ 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<PlatformListResponse<AnalyticsVulnerabilityTrendPoint>> {
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<PlatformListResponse<AnalyticsComponentTrendPoint>> {
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<T>(items: T[]): PlatformListResponse<T> {
return {
tenantId: 'default',
actorId: 'mock',
dataAsOf: new Date().toISOString(),
cached: false,
cacheTtlSeconds: 0,
items,
count: items.length,
};
}
}

View File

@@ -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<AocApi>('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<string>('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<string>('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<AocDashboardSummary> {
const traceId = generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<AocDashboardSummary>(
`${this.attestorBaseUrl}/dashboard/summary`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
startVerification(): Observable<AocVerificationRequest> {
const traceId = generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.post<AocVerificationRequest>(
`${this.attestorBaseUrl}/verifications`,
{},
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getVerificationStatus(requestId: string): Observable<AocVerificationRequest> {
const traceId = generateTraceId();
const headers = this.buildHeaders(traceId);
return this.http.get<AocVerificationRequest>(
`${this.attestorBaseUrl}/verifications/${encodeURIComponent(requestId)}`,
{ headers }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getViolationsByCode(code: string): Observable<ViolationDetail[]> {
const traceId = generateTraceId();
const headers = this.buildHeaders(traceId);
const params = new HttpParams().set('code', code);
return this.http.get<ViolationDetail[]>(
`${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);

View File

@@ -290,7 +290,7 @@ export class AttestationChainMockClient implements AttestationChainApi {
subjectDigest: string,
options?: AttestationQueryOptions
): Observable<AttestationNode[]> {
return of(this.mockChain.nodes);
return of([...this.mockChain.nodes]);
}
getRekorEntry(uuid: string): Observable<RekorLogEntry> {

View File

@@ -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<AuditBundleListResponse>(`${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<AuditBundleJobResponse>(`${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<AuditBundleJobResponse>(`${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';
}
}

View File

@@ -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<AdminUser[]>;
listRoles(tenantId?: string): Observable<AdminRole[]>;
listClients(tenantId?: string): Observable<AdminClient[]>;
listTokens(tenantId?: string): Observable<AdminToken[]>;
listTenants(): Observable<AdminTenant[]>;
createUser(request: CreateUserRequest): Observable<AdminUser>;
}
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
@@ -118,6 +126,12 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
}).pipe(map(r => r.tenants ?? []));
}
createUser(request: CreateUserRequest): Observable<AdminUser> {
return this.http.post<AdminUser>(`${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<AdminUser> {
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));
}
}

View File

@@ -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';
}
}

View File

@@ -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));
}
}

View File

@@ -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';
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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';
}
}

View File

@@ -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<EvidenceApi>('EVIDENCE_API');
export const EVIDENCE_API_BASE_URL = new InjectionToken<string>('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<EvidenceData> {
return this.http
.get<EvidenceData>(
`${this.baseUrl}/api/v1/evidence/advisories/${encodeURIComponent(advisoryId)}`,
{ headers: this.buildHeaders() },
)
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
getObservation(observationId: string): Observable<Observation> {
return this.http
.get<Observation>(
`${this.baseUrl}/api/v1/evidence/observations/${encodeURIComponent(observationId)}`,
{ headers: this.buildHeaders() },
)
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
getLinkset(linksetId: string): Observable<Linkset> {
return this.http
.get<Linkset>(
`${this.baseUrl}/api/v1/evidence/linksets/${encodeURIComponent(linksetId)}`,
{ headers: this.buildHeaders() },
)
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
return this.http
.get<PolicyEvidence | null>(
`${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<Blob> {
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<Blob> {
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<string, string> = {};
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[] = [

View File

@@ -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';
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<FeedMirrorApi>('FEED_MIRROR_API');
export const FEED_MIRROR_API_BASE_URL = new InjectionToken<string>('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<readonly FeedMirror[]> {
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<readonly FeedMirror[]>(`${this.baseUrl}/mirrors`, { params });
}
getMirror(mirrorId: string): Observable<FeedMirror> {
return this.http.get<FeedMirror>(
`${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}`
);
}
updateMirrorConfig(mirrorId: string, config: MirrorConfigUpdate): Observable<FeedMirror> {
return this.http.patch<FeedMirror>(
`${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}`,
config
);
}
triggerSync(request: MirrorSyncRequest): Observable<MirrorSyncResult> {
return this.http.post<MirrorSyncResult>(
`${this.baseUrl}/mirrors/${encodeURIComponent(request.mirrorId)}/sync`,
request
);
}
// ---- Snapshot operations ----
listSnapshots(mirrorId: string): Observable<readonly FeedSnapshot[]> {
return this.http.get<readonly FeedSnapshot[]>(
`${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}/snapshots`
);
}
getSnapshot(snapshotId: string): Observable<FeedSnapshot> {
return this.http.get<FeedSnapshot>(
`${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`
);
}
downloadSnapshot(snapshotId: string): Observable<SnapshotDownloadProgress> {
return this.http.post<SnapshotDownloadProgress>(
`${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}/download`,
{}
);
}
pinSnapshot(snapshotId: string, pinned: boolean): Observable<FeedSnapshot> {
return this.http.patch<FeedSnapshot>(
`${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`,
{ isPinned: pinned }
);
}
deleteSnapshot(snapshotId: string): Observable<void> {
return this.http.delete<void>(
`${this.baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`
);
}
updateRetentionConfig(config: SnapshotRetentionConfig): Observable<SnapshotRetentionConfig> {
return this.http.put<SnapshotRetentionConfig>(
`${this.baseUrl}/mirrors/${encodeURIComponent(config.mirrorId)}/retention`,
config
);
}
getRetentionConfig(mirrorId: string): Observable<SnapshotRetentionConfig> {
return this.http.get<SnapshotRetentionConfig>(
`${this.baseUrl}/mirrors/${encodeURIComponent(mirrorId)}/retention`
);
}
// ---- AirGap bundle operations ----
listBundles(): Observable<readonly AirGapBundle[]> {
return this.http.get<readonly AirGapBundle[]>(`${this.baseUrl}/bundles`);
}
getBundle(bundleId: string): Observable<AirGapBundle> {
return this.http.get<AirGapBundle>(
`${this.baseUrl}/bundles/${encodeURIComponent(bundleId)}`
);
}
createBundle(request: AirGapBundleRequest): Observable<AirGapBundle> {
return this.http.post<AirGapBundle>(`${this.baseUrl}/bundles`, request);
}
deleteBundle(bundleId: string): Observable<void> {
return this.http.delete<void>(
`${this.baseUrl}/bundles/${encodeURIComponent(bundleId)}`
);
}
downloadBundle(bundleId: string): Observable<SnapshotDownloadProgress> {
return this.http.post<SnapshotDownloadProgress>(
`${this.baseUrl}/bundles/${encodeURIComponent(bundleId)}/download`,
{}
);
}
// ---- AirGap import operations ----
validateImport(file: File): Observable<AirGapImportValidation> {
const formData = new FormData();
formData.append('file', file, file.name);
return this.http.post<AirGapImportValidation>(
`${this.baseUrl}/imports/validate`,
formData
);
}
startImport(bundleId: string): Observable<AirGapImportProgress> {
return this.http.post<AirGapImportProgress>(
`${this.baseUrl}/imports`,
{ bundleId }
);
}
getImportProgress(importId: string): Observable<AirGapImportProgress> {
return this.http.get<AirGapImportProgress>(
`${this.baseUrl}/imports/${encodeURIComponent(importId)}`
);
}
// ---- Version lock operations ----
listVersionLocks(): Observable<readonly FeedVersionLock[]> {
return this.http.get<readonly FeedVersionLock[]>(`${this.baseUrl}/version-locks`);
}
getVersionLock(feedType: FeedType): Observable<FeedVersionLock | null> {
return this.http.get<FeedVersionLock | null>(
`${this.baseUrl}/version-locks/${encodeURIComponent(feedType)}`
);
}
setVersionLock(request: FeedVersionLockRequest): Observable<FeedVersionLock> {
return this.http.put<FeedVersionLock>(
`${this.baseUrl}/version-locks/${encodeURIComponent(request.feedType)}`,
request
);
}
removeVersionLock(lockId: string): Observable<void> {
return this.http.delete<void>(
`${this.baseUrl}/version-locks/${encodeURIComponent(lockId)}`
);
}
// ---- Offline status ----
getOfflineSyncStatus(): Observable<OfflineSyncStatus> {
return this.http.get<OfflineSyncStatus>(`${this.baseUrl}/offline-status`);
}
}
// ============================================================================
// Mock API Implementation
// ============================================================================

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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)}%`;
}

View File

@@ -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 {

View File

@@ -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<PolicyGatesApi>('POLICY_GATES_API');
export const POLICY_GATES_API_BASE_URL = new InjectionToken<string>('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<readonly PolicyProfile[]> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
const params: Record<string, string> = {};
if (!includeBuiltin) {
params['includeBuiltin'] = 'false';
}
return this.http.get<PolicyProfile[]>(`${this.baseUrl}/gate/profiles`, {
headers: this.buildHeaders(tenant, traceId),
params,
}).pipe(
catchError(() => of([] as PolicyProfile[]))
);
}
getProfile(profileId: string): Observable<PolicyProfile> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.get<PolicyProfile>(`${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}`, {
headers: this.buildHeaders(tenant, traceId),
});
}
getProfileByName(name: string): Observable<PolicyProfile | null> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.get<PolicyProfile>(`${this.baseUrl}/gate/profiles/by-name/${encodeURIComponent(name)}`, {
headers: this.buildHeaders(tenant, traceId),
}).pipe(
catchError(() => of(null))
);
}
createProfile(request: CreatePolicyProfileRequest): Observable<PolicyProfile> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.post<PolicyProfile>(`${this.baseUrl}/gate/profiles`, request, {
headers: this.buildHeaders(tenant, traceId),
});
}
updateProfile(profileId: string, request: UpdatePolicyProfileRequest): Observable<PolicyProfile> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.patch<PolicyProfile>(
`${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}`,
request,
{ headers: this.buildHeaders(tenant, traceId) }
);
}
deleteProfile(profileId: string): Observable<boolean> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.delete<void>(
`${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}`,
{ headers: this.buildHeaders(tenant, traceId) }
).pipe(
map(() => true),
catchError(() => of(false))
);
}
setDefaultProfile(profileId: string): Observable<void> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.post<void>(
`${this.baseUrl}/gate/profiles/${encodeURIComponent(profileId)}/set-default`,
{},
{ headers: this.buildHeaders(tenant, traceId) }
);
}
getEffectiveProfile(): Observable<PolicyProfile> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.get<PolicyProfile>(`${this.baseUrl}/gate/profiles/effective`, {
headers: this.buildHeaders(tenant, traceId),
});
}
validatePolicyYaml(yaml: string): Observable<PolicyValidationResult> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.post<PolicyValidationResult>(
`${this.baseUrl}/gate/profiles/validate`,
{ yaml },
{ headers: this.buildHeaders(tenant, traceId) }
);
}
// ---- Simulation ----
simulate(request: PolicySimulationRequest): Observable<PolicySimulationResult> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.post<PolicySimulationResult>(
`${this.baseUrl}/gate/simulate`,
request,
{ headers: this.buildHeaders(tenant, traceId) }
);
}
simulateBundle(promotionId: string, profileIdOrName?: string): Observable<BundleSimulationResult> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
const body: Record<string, string> = { promotionId };
if (profileIdOrName) {
body['profileIdOrName'] = profileIdOrName;
}
return this.http.post<BundleSimulationResult>(
`${this.baseUrl}/gate/simulate/bundle`,
body,
{ headers: this.buildHeaders(tenant, traceId) }
);
}
// ---- Feed freshness ----
getFeedFreshnessSummary(): Observable<FeedFreshnessSummary> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.get<FeedFreshnessSummary>(`${this.baseUrl}/gate/feeds/freshness`, {
headers: this.buildHeaders(tenant, traceId),
});
}
getFeedFreshness(feedName: string): Observable<FeedFreshness | null> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.get<FeedFreshness>(
`${this.baseUrl}/gate/feeds/freshness/${encodeURIComponent(feedName)}`,
{ headers: this.buildHeaders(tenant, traceId) }
).pipe(
catchError(() => of(null))
);
}
// ---- Air-gap status ----
getAirGapStatus(): Observable<AirGapStatus> {
const traceId = generateTraceId();
const tenant = this.resolveTenant();
return this.http.get<AirGapStatus>(`${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
// =============================================================================

View File

@@ -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 {

View File

@@ -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<Release> {
return this.http
.get<Release>(`${this.baseUrl}/${encodeURIComponent(releaseId)}`)
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
listReleases(): Observable<readonly Release[]> {
return this.http
.get<readonly Release[]>(this.baseUrl)
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
publishRelease(releaseId: string): Observable<Release> {
return this.http
.post<Release>(`${this.baseUrl}/${encodeURIComponent(releaseId)}/publish`, {})
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
cancelRelease(releaseId: string): Observable<Release> {
return this.http
.post<Release>(`${this.baseUrl}/${encodeURIComponent(releaseId)}/cancel`, {})
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
return this.http
.get<DeterminismFeatureFlags>(`${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
// ============================================================================

View File

@@ -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';
}
}

View File

@@ -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<Schedule[]> {
return of([...this.schedules]).pipe(delay(300));
}
getSchedule(id: string): Observable<Schedule> {
const s = this.schedules.find(s => s.id === id);
return of(s ?? this.schedules[0]).pipe(delay(200));
}
createSchedule(dto: CreateScheduleDto): Observable<Schedule> {
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<Schedule> {
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<void> {
this.schedules = this.schedules.filter(s => s.id !== id);
return of(void 0).pipe(delay(200));
}
pauseSchedule(id: string): Observable<void> {
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<void> {
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<void> {
return of(void 0).pipe(delay(200));
}
previewImpact(schedule: CreateScheduleDto): Observable<ScheduleImpactPreview> {
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));
}
}

View File

@@ -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<ScoreSummary> {

View File

@@ -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<FindingDto[]>(`${this.baseUrl}/api/v1/findings`, {
return this.http.get<any>(`${this.baseUrl}/api/v1/findings/summaries`, {
params,
headers: this.buildHeaders(),
});
}).pipe(
map((res: any) => Array.isArray(res) ? res : (res?.items ?? [])),
);
}
getFinding(findingId: string): Observable<FindingDetailDto> {
return this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}`, {
return this.http.get<FindingDetailDto>(`${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<FindingDto[]> {
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<FindingDetailDto> {
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));
}
}

View File

@@ -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<SecurityOverviewData> {
const headers = this.buildHeaders();
const findings$ = this.http.get<any[]>(
`${this.scannerBaseUrl}/api/v1/findings`,
const findings$ = this.http.get<any>(
`${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<any[]>(
`${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<SecurityOverviewData> {
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));
}
}

View File

@@ -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 {

View File

@@ -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';
}
}

View File

@@ -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 {

View File

@@ -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<VexStatementSearchResponse>(`${this.baseUrl}/statements`, { headers, params: httpParams }).pipe(
return this.http.get<VexStatementSearchResponse>(`${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<VexStatement>(`${this.baseUrl}/statements/${encodeURIComponent(statementId)}`, { headers }).pipe(
return this.http.get<VexStatement>(`${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<VexHubStats>(`${this.baseUrl}/stats`, { headers }).pipe(
return this.http.get<any>(`${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}`);
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}
}
}

View File

@@ -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<StellaOpsScope, string> = {
'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',
};
/**

View File

@@ -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) {

View File

@@ -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<unknown>): HttpRequest<unknown> {
const headers: Record<string, string> = {};
// 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();

View File

@@ -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<AuthorityConfig> {
const overrides: Record<string, string> = {};
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;
}
}

View File

@@ -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<string, unknown>;
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 })

View File

@@ -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' ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>' : verdict === 'partial' ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z"/><path d="M12 2a10 10 0 0 1 0 20z" fill="currentColor"/></svg>' : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
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' ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/></svg>',
details: verdict === 'fixed' ? 'Edge removed in patch' : 'Edge still present'
}
]

View File

@@ -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,
})

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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); }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -56,7 +56,7 @@ export type UserRole = 'developer' | 'security' | 'audit';
@if (error()) {
<div class="compare-view__error" role="alert">
<span></span> {{ error() }}
<span><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span> {{ error() }}
</div>
}

View File

@@ -29,7 +29,7 @@ import { ScanDigest } from '../services/compare.service';
<div class="trust-indicator">
<span class="trust-indicator__label">Signature</span>
<div class="trust-indicator__value" [class]="signatureClass()">
<span class="trust-indicator__icon">{{ signatureIcon() }}</span>
<span class="trust-indicator__icon" [innerHTML]="signatureIcon()"></span>
<span>{{ signatureText() }}</span>
</div>
</div>
@@ -144,11 +144,12 @@ export class TrustIndicatorsComponent {
readonly signatureClass = computed(() => this.current()?.signatureStatus ?? 'unknown');
readonly signatureIcon = computed(() => {
const svg = (d: string) => `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">${d}</svg>`;
switch (this.current()?.signatureStatus) {
case 'valid': return '✓';
case 'invalid': return '✗';
case 'missing': return '?';
default: return '—';
case 'valid': return svg('<polyline points="20 6 9 17 4 12"/>');
case 'invalid': return svg('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>');
case 'missing': return svg('<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>');
default: return svg('<line x1="5" y1="12" x2="19" y2="12"/>');
}
});

View File

@@ -172,7 +172,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
<div class="modal-content" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Audit Event Details</h2>
<button class="btn-close" (click)="closeDetails()">×</button>
<button class="btn-close" (click)="closeDetails()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<div class="detail-row">

Some files were not shown because too many files have changed in this diff Show More