From c34fb7256d021860877dcf718ab7352fc171d441 Mon Sep 17 00:00:00 2001 From: StellaOps Bot Date: Thu, 27 Nov 2025 08:51:10 +0200 Subject: [PATCH] up --- docs/24_OFFLINE_KIT.md | 148 ++- docs/benchmarks/signals/bench-determinism.md | 8 +- .../SPRINT_0132_0001_0001_scanner_surface.md | 3 +- .../SPRINT_0135_0001_0001_scanner_surface.md | 15 +- .../SPRINT_0172_0001_0002_notifier_ii.md | 9 +- .../SPRINT_0173_0001_0003_notifier_iii.md | 3 +- docs/implplan/SPRINT_0201_0001_0001_cli_i.md | 5 +- docs/implplan/SPRINT_0208_0001_0001_sdk.md | 5 +- docs/implplan/SPRINT_0209_0001_0001_ui_i.md | 25 +- ...1_0001_0001_reachability_evidence_chain.md | 9 +- docs/implplan/SPRINT_0512_0001_0001_bench.md | 1 + ...4_0001_0001_sovereign_crypto_enablement.md | 9 +- ...RINT_186_record_deterministic_execution.md | 4 +- docs/implplan/tasks-all.md | 14 +- docs/modules/cli/guides/attest.md | 71 ++ .../scanner/deterministic-execution.md | 1 + .../bench-determinism-artifacts.tgz | Bin 693 -> 976 bytes out/bench-determinism/dataset.sha256 | 2 + out/bench-determinism/results-reach.csv | 3 + out/bench-determinism/results-reach.json | 5 + out/bench-determinism/summary.txt | 2 +- scripts/bench/README.md | 4 +- scripts/bench/determinism-run.sh | 23 + .../DsseEnvelopeExtensions.cs | 73 ++ .../StellaOps.Attestation/DsseHelper.cs | 3 +- .../Signing/AuthorityDsseStatementSigner.cs | 116 ++ .../Signing/AuthoritySignerAdapter.cs | 32 + .../StellaOps.Authority.csproj | 1 + .../StellaOps.Bench/Determinism/.gitignore | 2 + .../inputs/graphs/sample-graph.json | 11 + .../inputs/runtime/sample-runtime.ndjson | 1 + .../Determinism/results/inputs.sha256 | 3 - .../Determinism/results/results.csv | 21 - .../Determinism/results/summary.json | 3 - .../Determinism/run_reachability.py | 94 ++ .../tests/test_run_reachability.py | 33 + .../StellaOps.Cli/Commands/CommandFactory.cs | 1055 +++++++++-------- .../StellaOps.Cli/Commands/CommandHandlers.cs | 168 +++ .../Contracts/DeadLetterContracts.cs | 137 +++ .../Contracts/RetentionContracts.cs | 143 +++ .../Contracts/SecurityContracts.cs | 305 +++++ .../StellaOps.Notifier.WebService/Program.cs | 742 ++++++++++++ .../Channels/WebhookChannelAdapter.cs | 37 +- .../DeadLetter/IDeadLetterService.cs | 185 +++ .../DeadLetter/InMemoryDeadLetterService.cs | 294 +++++ .../Observability/DefaultNotifyMetrics.cs | 233 ++++ .../Observability/INotifyMetrics.cs | 98 ++ .../DefaultRetentionPolicyService.cs | 298 +++++ .../Retention/IRetentionPolicyService.cs | 181 +++ .../Security/DefaultHtmlSanitizer.cs | 509 ++++++++ .../DefaultTenantIsolationValidator.cs | 221 ++++ .../Security/DefaultWebhookSecurityService.cs | 329 +++++ .../Security/HmacAckTokenService.cs | 292 +++++ .../Security/IAckTokenService.cs | 141 +++ .../Security/IHtmlSanitizer.cs | 177 +++ .../Security/ITenantIsolationValidator.cs | 190 +++ .../Security/IWebhookSecurityService.cs | 147 +++ .../Repositories/NotifyChannelRepository.cs | 49 +- .../Repositories/NotifyRuleRepository.cs | 49 +- .../Repositories/NotifyTemplateRepository.cs | 49 +- .../Tenancy/ITenantContext.cs | 145 +++ .../Tenancy/TenantAwareRepository.cs | 109 ++ .../Tenancy/TenantScopedId.cs | 86 ++ .../Determinism/DeterminismContext.cs | 5 +- .../Surface/SurfaceManifestStageExecutor.cs | 52 +- .../StellaOps.Scanner.Worker/Program.cs | 3 +- .../Internal/NodePackageCollector.cs | 2 +- .../Observations/RubyObservationBuilder.cs | 140 ++- .../Observations/RubyObservationDocument.cs | 32 + .../Observations/RubyObservationSerializer.cs | 82 ++ .../Internal/RubyLockCollector.cs | 66 ++ .../Internal/RubyLockEntry.cs | 1 + .../Internal/RubyLockParser.cs | 76 +- .../Internal/RubyRuntimeGraphBuilder.cs | 32 + .../Internal/RubyVendorArtifactCollector.cs | 59 + .../Internal/Runtime/RubyRuntimeShim.cs | 307 +++++ .../Runtime/RubyRuntimeTraceReader.cs | 268 +++++ .../Runtime/RubyRuntimeTraceRunner.cs | 164 +++ .../RubyLanguageAnalyzer.cs | 12 +- .../TASKS.md | 5 + .../manifest.json | 24 + .../Fixtures/lang/ruby/cli-app/Gemfile | 10 + .../Fixtures/lang/ruby/cli-app/Gemfile.lock | 29 + .../Fixtures/lang/ruby/cli-app/expected.json | 226 ++++ .../lang/ruby/complex-app/expected.json | 5 +- .../lang/ruby/simple-app/expected.json | 5 +- .../RubyLanguageAnalyzerTests.cs | 14 + ...s.Scanner.Analyzers.Lang.Ruby.Tests.csproj | 2 + .../SurfaceManifestStageExecutorTests.cs | 54 +- src/Sdk/StellaOps.Sdk.Generator/TASKS.md | 4 +- src/Web/StellaOps.Web/src/app/app.routes.ts | 35 + .../src/app/core/api/aoc.client.ts | 364 ++++++ .../src/app/core/api/aoc.models.ts | 152 +++ .../src/app/core/api/evidence.client.ts | 323 +++++ .../src/app/core/api/evidence.models.ts | 189 +++ .../src/app/core/api/release.client.ts | 373 ++++++ .../src/app/core/api/release.models.ts | 161 +++ .../src/app/core/api/scanner.models.ts | 85 ++ .../src/app/core/auth/auth.service.ts | 125 ++ .../StellaOps.Web/src/app/core/auth/index.ts | 16 + .../StellaOps.Web/src/app/core/auth/scopes.ts | 166 +++ .../evidence/evidence-page.component.ts | 200 ++++ .../evidence/evidence-panel.component.html | 591 +++++++++ .../evidence/evidence-panel.component.scss | 1012 ++++++++++++++++ .../evidence/evidence-panel.component.ts | 255 ++++ .../src/app/features/evidence/index.ts | 2 + .../graph/graph-explorer.component.ts | 23 + .../src/app/features/releases/index.ts | 3 + .../policy-gate-indicator.component.ts | 328 +++++ .../releases/release-flow.component.html | 331 ++++++ .../releases/release-flow.component.scss | 661 +++++++++++ .../releases/release-flow.component.ts | 229 ++++ .../releases/remediation-hints.component.ts | 507 ++++++++ .../scans/determinism-badge.component.ts | 608 ++++++++++ .../features/scans/entropy-panel.component.ts | 950 +++++++++++++++ .../scans/entropy-policy-banner.component.ts | 659 ++++++++++ .../scans/scan-detail-page.component.html | 27 + .../scans/scan-detail-page.component.scss | 40 + .../scans/scan-detail-page.component.ts | 5 +- .../sources/aoc-dashboard.component.html | 296 +++++ .../sources/aoc-dashboard.component.scss | 752 ++++++++++++ .../sources/aoc-dashboard.component.ts | 207 ++++ .../src/app/features/sources/index.ts | 2 + .../sources/violation-detail.component.ts | 527 ++++++++ .../src/app/testing/scan-fixtures.ts | 231 +++- .../TimeStatusServiceTests.cs | 1 + 126 files changed, 18553 insertions(+), 693 deletions(-) create mode 100644 out/bench-determinism/dataset.sha256 create mode 100644 out/bench-determinism/results-reach.csv create mode 100644 out/bench-determinism/results-reach.json create mode 100644 src/Attestor/StellaOps.Attestation/DsseEnvelopeExtensions.cs create mode 100644 src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityDsseStatementSigner.cs create mode 100644 src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthoritySignerAdapter.cs create mode 100644 src/Bench/StellaOps.Bench/Determinism/.gitignore create mode 100644 src/Bench/StellaOps.Bench/Determinism/inputs/graphs/sample-graph.json create mode 100644 src/Bench/StellaOps.Bench/Determinism/inputs/runtime/sample-runtime.ndjson delete mode 100644 src/Bench/StellaOps.Bench/Determinism/results/inputs.sha256 delete mode 100644 src/Bench/StellaOps.Bench/Determinism/results/results.csv delete mode 100644 src/Bench/StellaOps.Bench/Determinism/results/summary.json create mode 100644 src/Bench/StellaOps.Bench/Determinism/run_reachability.py create mode 100644 src/Bench/StellaOps.Bench/Determinism/tests/test_run_reachability.py create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/DeadLetterContracts.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RetentionContracts.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/SecurityContracts.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/IDeadLetterService.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/InMemoryDeadLetterService.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/DefaultNotifyMetrics.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/INotifyMetrics.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/DefaultRetentionPolicyService.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/IRetentionPolicyService.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultHtmlSanitizer.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultTenantIsolationValidator.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultWebhookSecurityService.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/HmacAckTokenService.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IAckTokenService.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IHtmlSanitizer.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/ITenantIsolationValidator.cs create mode 100644 src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IWebhookSecurityService.cs create mode 100644 src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/ITenantContext.cs create mode 100644 src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantAwareRepository.cs create mode 100644 src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantScopedId.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeShim.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceReader.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceRunner.cs create mode 100644 src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/manifest.json create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile.lock create mode 100644 src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/expected.json create mode 100644 src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/release.client.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/release.models.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/auth/auth.service.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/auth/index.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/auth/scopes.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence/index.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/releases/index.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/releases/policy-gate-indicator.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html create mode 100644 src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.scss create mode 100644 src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/releases/remediation-hints.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/scans/determinism-badge.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/scans/entropy-panel.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/scans/entropy-policy-banner.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html create mode 100644 src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss create mode 100644 src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/sources/index.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/sources/violation-detail.component.ts diff --git a/docs/24_OFFLINE_KIT.md b/docs/24_OFFLINE_KIT.md index 7f58b7765..6a643297f 100755 --- a/docs/24_OFFLINE_KIT.md +++ b/docs/24_OFFLINE_KIT.md @@ -13,23 +13,23 @@ completely isolated network: | Component | Contents | |-----------|----------| | **Merged vulnerability feeds** | OSV, GHSA plus optional NVD 2.0, CNNVD, CNVD, ENISA, JVN and BDU | -| **Container images** | `stella-ops`, *Zastava* sidecar, `advisory-ai-web`, and `advisory-ai-worker` (x86‑64 & arm64) | +| **Container images** | `stella-ops`, *Zastava* sidecar, `advisory-ai-web`, and `advisory-ai-worker` (x86‑64 & arm64) | | **Provenance** | Cosign signature, SPDX 2.3 SBOM, in‑toto SLSA attestation | -| **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. | -| **Delta patches** | Daily diff bundles keep size \< 350 MB | -| **Scanner plug-ins** | OS analyzers plus the Node.js, Go, .NET, Python, and Rust language analyzers packaged under `plugins/scanner/analyzers/**` with manifests so Workers load deterministically offline. | -| **Debug store** | `.debug` artefacts laid out under `debug/.build-id//.debug` with `debug/debug-manifest.json` mapping build-ids to originating images for symbol retrieval. | -| **Telemetry collector bundle** | `telemetry/telemetry-offline-bundle.tar.gz` plus `.sha256`, containing OTLP collector config, Helm/Compose overlays, and operator instructions. | -| **CLI + Task Packs** | `cli/` binaries from `release/cli`, Task Runner bootstrap (`bootstrap/task-runner/task-runner.yaml.sample`), and task-pack docs under `docs/task-packs/**` + `docs/modules/taskrunner/**`. | -| **Orchestrator/Export/Notifier kits** | Orchestrator service, worker SDK, Postgres snapshot, dashboards (`orchestrator/**`), Export Center bundles (`export-center/**`), Notifier offline packs (`notifier/**`). | -| **Container air-gap bundles** | Any tar/tgz under `containers/` or `images/` (mirrored registries) plus `docs/airgap/mirror-bundles.md`. | -| **Surface.Secrets** | Encrypted secrets bundles and manifests (`surface-secrets/**`) for sealed-mode bootstrap. | +| **Attested manifest** | `offline-manifest.json` + detached JWS covering bundle metadata, signed during export. | +| **Delta patches** | Daily diff bundles keep size \< 350 MB | +| **Scanner plug-ins** | OS analyzers plus the Node.js, Go, .NET, Python, Ruby, and Rust language analyzers packaged under `plugins/scanner/analyzers/**` with manifests so Workers load deterministically offline. | +| **Debug store** | `.debug` artefacts laid out under `debug/.build-id//.debug` with `debug/debug-manifest.json` mapping build-ids to originating images for symbol retrieval. | +| **Telemetry collector bundle** | `telemetry/telemetry-offline-bundle.tar.gz` plus `.sha256`, containing OTLP collector config, Helm/Compose overlays, and operator instructions. | +| **CLI + Task Packs** | `cli/` binaries from `release/cli`, Task Runner bootstrap (`bootstrap/task-runner/task-runner.yaml.sample`), and task-pack docs under `docs/task-packs/**` + `docs/modules/taskrunner/**`. | +| **Orchestrator/Export/Notifier kits** | Orchestrator service, worker SDK, Postgres snapshot, dashboards (`orchestrator/**`), Export Center bundles (`export-center/**`), Notifier offline packs (`notifier/**`). | +| **Container air-gap bundles** | Any tar/tgz under `containers/` or `images/` (mirrored registries) plus `docs/airgap/mirror-bundles.md`. | +| **Surface.Secrets** | Encrypted secrets bundles and manifests (`surface-secrets/**`) for sealed-mode bootstrap. | **RU BDU note:** ship the official Russian Trusted Root/Sub CA bundle (`certificates/russian_trusted_bundle.pem`) inside the kit so `concelier:httpClients:source.bdu:trustedRootPaths` can resolve it when the service runs in an air‑gapped network. Drop the most recent `vulxml.zip` alongside the kit if operators need a cold-start cache. -**Language analyzers:** the kit now carries the restart-only Node.js, Go, .NET, Python, and Rust plug-ins (`plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Node/`, `...Lang.Go/`, `...Lang.DotNet/`, `...Lang.Python/`, `...Lang.Rust/`). Drop the directories alongside Worker binaries so the unified plug-in catalog can load them without outbound fetches. - -**Advisory AI volume primer:** ship a tarball containing empty `queue/`, `plans/`, and `outputs/` directories plus their ownership metadata. During import, extract it onto the RWX volume used by `advisory-ai-web` and `advisory-ai-worker` so pods start with the expected directory tree even on air-gapped nodes. +**Language analyzers:** the kit now carries the restart-only Node.js, Go, .NET, Python, Ruby, and Rust plug-ins (`plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Node/`, `...Lang.Go/`, `...Lang.DotNet/`, `...Lang.Python/`, `...Lang.Ruby/`, `...Lang.Rust/`). Drop the directories alongside Worker binaries so the unified plug-in catalog can load them without outbound fetches. The Ruby analyzer includes optional runtime capture via TracePoint; set `STELLA_RUBY_ENTRYPOINT` to enable runtime evidence collection. + +**Advisory AI volume primer:** ship a tarball containing empty `queue/`, `plans/`, and `outputs/` directories plus their ownership metadata. During import, extract it onto the RWX volume used by `advisory-ai-web` and `advisory-ai-worker` so pods start with the expected directory tree even on air-gapped nodes. *Scanner core:* C# 12 on **.NET {{ dotnet }}**. *Imports are idempotent and atomic — no service downtime.* @@ -50,15 +50,15 @@ The helper copies `debug/.build-id/**`, validates `debug/debug-manifest.json` ag ## 0.1 · Automated packaging -The packaging workflow is scripted via `ops/offline-kit/build_offline_kit.py`. -It verifies the release artefacts, runs the Python analyzer smoke suite, mirrors the debug store, and emits a deterministic tarball + manifest set. - -What it picks up automatically (if present under `--release-dir`): -- `cli/**` → CLI binaries and installers. -- `containers/**` or `images/**` → air-gap container bundles. -- `orchestrator/{service,worker-sdk,postgres,dashboards}/**`. -- `export-center/**`, `notifier/**`, `surface-secrets/**`. -- Docs: `docs/task-packs/**`, `docs/modules/taskrunner/**`, `docs/airgap/mirror-bundles.md`. +The packaging workflow is scripted via `ops/offline-kit/build_offline_kit.py`. +It verifies the release artefacts, runs the Python analyzer smoke suite, mirrors the debug store, and emits a deterministic tarball + manifest set. + +What it picks up automatically (if present under `--release-dir`): +- `cli/**` → CLI binaries and installers. +- `containers/**` or `images/**` → air-gap container bundles. +- `orchestrator/{service,worker-sdk,postgres,dashboards}/**`. +- `export-center/**`, `notifier/**`, `surface-secrets/**`. +- Docs: `docs/task-packs/**`, `docs/modules/taskrunner/**`, `docs/airgap/mirror-bundles.md`. ```bash python ops/offline-kit/build_offline_kit.py \ @@ -72,13 +72,13 @@ python ops/offline-kit/build_offline_kit.py \ python ops/devops/telemetry/package_offline_bundle.py --output out/telemetry/telemetry-offline-bundle.tar.gz ``` -Outputs: - -- `stella-ops-offline-kit--.tar.gz` — bundle (mtime/uid/gid forced to zero for reproducibility) -- `stella-ops-offline-kit--.tar.gz.sha256` — bundle digest -- `manifest/offline-manifest.json` + `.sha256` — inventories every file in the bundle -- `.metadata.json` — descriptor consumed by the CLI/Console import tooling; includes `counts` for `cli`, `taskPacksDocs`, `containers`, `orchestrator`, `exportCenter`, `notifier`, `surfaceSecrets` so operators can sanity-check bundle composition without unpacking -- `telemetry/telemetry-offline-bundle.tar.gz` + `.sha256` — packaged OTLP collector assets for environments without upstream access +Outputs: + +- `stella-ops-offline-kit--.tar.gz` — bundle (mtime/uid/gid forced to zero for reproducibility) +- `stella-ops-offline-kit--.tar.gz.sha256` — bundle digest +- `manifest/offline-manifest.json` + `.sha256` — inventories every file in the bundle +- `.metadata.json` — descriptor consumed by the CLI/Console import tooling; includes `counts` for `cli`, `taskPacksDocs`, `containers`, `orchestrator`, `exportCenter`, `notifier`, `surfaceSecrets` so operators can sanity-check bundle composition without unpacking +- `telemetry/telemetry-offline-bundle.tar.gz` + `.sha256` — packaged OTLP collector assets for environments without upstream access - `plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/*.sig` (+ `.sha256`) — Cosign signatures for the Python analyzer DLL and manifest ### Policy Gateway configuration bundle @@ -175,31 +175,49 @@ Example excerpt (2025-10-23 kit) showing the Go and .NET analyzer plug-in payloa "size": 31896, "capturedAt": "2025-10-26T00:00:00Z" } -{ - "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/manifest.json", - "sha256": "668ad9a1a35485628677b639db4d996d1e25f62021680a81a22482483800e557", - "size": 648, - "capturedAt": "2025-10-26T00:00:00Z" -} -{ - "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.dll", - "sha256": "d90ba8b6ace7d98db563b1dec178d57ac09df474e1342fa1daa38bd55e17b185", - "size": 54784, - "capturedAt": "2025-11-01T00:00:00Z" -} -{ - "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.pdb", - "sha256": "6fac88640a4980d2bb8f7ea2dd2f3d0a521b90fd30ae3a84981575d5f76fa3df", - "size": 36636, - "capturedAt": "2025-11-01T00:00:00Z" -} -{ - "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/manifest.json", - "sha256": "1ec47d1a2103ad5eff23e903532cb76b1ed7ded85d301c1a6631ff21aa966ed4", - "size": 658, - "capturedAt": "2025-11-01T00:00:00Z" -} -``` +{ + "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/manifest.json", + "sha256": "668ad9a1a35485628677b639db4d996d1e25f62021680a81a22482483800e557", + "size": 648, + "capturedAt": "2025-10-26T00:00:00Z" +} +{ + "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.dll", + "sha256": "", + "size": 0, + "capturedAt": "2025-11-27T00:00:00Z" +} +{ + "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.pdb", + "sha256": "", + "size": 0, + "capturedAt": "2025-11-27T00:00:00Z" +} +{ + "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/manifest.json", + "sha256": "", + "size": 0, + "capturedAt": "2025-11-27T00:00:00Z" +} +{ + "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.dll", + "sha256": "d90ba8b6ace7d98db563b1dec178d57ac09df474e1342fa1daa38bd55e17b185", + "size": 54784, + "capturedAt": "2025-11-01T00:00:00Z" +} +{ + "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.pdb", + "sha256": "6fac88640a4980d2bb8f7ea2dd2f3d0a521b90fd30ae3a84981575d5f76fa3df", + "size": 36636, + "capturedAt": "2025-11-01T00:00:00Z" +} +{ + "name": "plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Rust/manifest.json", + "sha256": "1ec47d1a2103ad5eff23e903532cb76b1ed7ded85d301c1a6631ff21aa966ed4", + "size": 658, + "capturedAt": "2025-11-01T00:00:00Z" +} +``` --- @@ -245,25 +263,25 @@ The Offline Kit carries the same helper scripts under `scripts/`: ### Authority scope sanity check Offline installs rely on the bundled `etc/authority.yaml.sample`. Before promoting the kit, confirm the sample clients keep the Aggregation-Only guardrails: - -- `aoc-verifier` requests `aoc:verify`, `advisory:read`, and `vex:read`. -- `signals-uploader` requests `signals:write`, `signals:read`, and `aoc:verify`. -- `airgap-operator` requests `airgap:status:read`, `airgap:import`, and `airgap:seal`. -- `task-runner` requests `packs.run` and `packs.read` for execution flows. -- `pack-approver` requests `packs.approve` (plus `packs.read`) for automation that resumes runs after approvals. -- `packs-registry` requests `packs.write` and `packs.read` for publishing bundles. - -Authority now rejects tokens that request `advisory:read`, `vex:read`, or any `signals:*` scope without `aoc:verify`; the sample has been updated to match. Air-gap scopes (`airgap:*`) also require an explicit tenant assignment—match the updated roles (`airgap-viewer`, `airgap-operator`, `airgap-admin`) so automation fails closed when misconfigured. + +- `aoc-verifier` requests `aoc:verify`, `advisory:read`, and `vex:read`. +- `signals-uploader` requests `signals:write`, `signals:read`, and `aoc:verify`. +- `airgap-operator` requests `airgap:status:read`, `airgap:import`, and `airgap:seal`. +- `task-runner` requests `packs.run` and `packs.read` for execution flows. +- `pack-approver` requests `packs.approve` (plus `packs.read`) for automation that resumes runs after approvals. +- `packs-registry` requests `packs.write` and `packs.read` for publishing bundles. + +Authority now rejects tokens that request `advisory:read`, `vex:read`, or any `signals:*` scope without `aoc:verify`; the sample has been updated to match. Air-gap scopes (`airgap:*`) also require an explicit tenant assignment—match the updated roles (`airgap-viewer`, `airgap-operator`, `airgap-admin`) so automation fails closed when misconfigured. **Quick smoke test:** before import, verify the tarball carries the Go analyzer plug-in: ```bash -tar -tzf stella-ops-offline-kit-.tgz 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Go/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.DotNet/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/*' +tar -tzf stella-ops-offline-kit-.tgz 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Go/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.DotNet/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/*' 'plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Ruby/*' ``` The manifest lookup above and this `tar` listing should both surface the Go analyzer DLL, PDB, and manifest entries before the kit is promoted. -> **Release guardrail.** The automated release pipeline now publishes the Python and Rust plug-ins from source and executes `dotnet run --project src/Tools/LanguageAnalyzerSmoke --configuration Release -- --repo-root --analyzer ` to validate manifest integrity and cold/warm determinism within the < 30 s / < 5 s budgets (differences versus repository goldens are logged for triage). Run `ops/offline-kit/run-python-analyzer-smoke.sh` and `ops/offline-kit/run-rust-analyzer-smoke.sh` locally before shipping a refreshed kit if you rebuild artefacts outside CI or when preparing the air-gap bundle. +> **Release guardrail.** The automated release pipeline now publishes the Python, Ruby, and Rust plug-ins from source and executes `dotnet run --project src/Tools/LanguageAnalyzerSmoke --configuration Release -- --repo-root --analyzer ` to validate manifest integrity and cold/warm determinism within the < 30 s / < 5 s budgets (differences versus repository goldens are logged for triage). Run `ops/offline-kit/run-python-analyzer-smoke.sh` and `ops/offline-kit/run-ruby-analyzer-smoke.sh`, and `ops/offline-kit/run-rust-analyzer-smoke.sh` locally before shipping a refreshed kit if you rebuild artefacts outside CI or when preparing the air-gap bundle. ### Debug store mirror diff --git a/docs/benchmarks/signals/bench-determinism.md b/docs/benchmarks/signals/bench-determinism.md index d41d25c17..7cea6488f 100644 --- a/docs/benchmarks/signals/bench-determinism.md +++ b/docs/benchmarks/signals/bench-determinism.md @@ -64,15 +64,15 @@ python run_bench.py --sboms inputs/sboms/*.json --vex inputs/vex/*.json \ --config configs/scanners.json --shuffle --output results # Reachability dataset (optional) -python run_reachability.py --graphs ../reachability/graphs/*.json \ - --runtime ../reachability/runtime/*.ndjson.gz --output results-reach.csv +python run_reachability.py --graphs inputs/graphs/*.json \ + --runtime inputs/runtime/*.ndjson --output results ``` -Outputs are written to `results.csv` (determinism) and `results-reach.csv` (reachability stability) plus SHA manifests. +Outputs are written to `results.csv` (determinism), `results-reach.csv`/`results-reach.json` (reachability hashes), and manifests `inputs.sha256` + `dataset.sha256`. ## How to run (CI) -- Workflow `.gitea/workflows/bench-determinism.yml` calls `scripts/bench/determinism-run.sh`, which runs the harness with the bundled mock scanner and uploads `out/bench-determinism/**` (results, manifests, summary). Set `DET_EXTRA_INPUTS` to include frozen feed bundles in `inputs.sha256`. +- Workflow `.gitea/workflows/bench-determinism.yml` calls `scripts/bench/determinism-run.sh`, which runs the harness with the bundled mock scanner and uploads `out/bench-determinism/**` (results, manifests, summary). Set `DET_EXTRA_INPUTS` to include frozen feed bundles in `inputs.sha256`; optional `DET_REACH_GRAPHS`/`DET_REACH_RUNTIME` adds reachability hashes + `dataset.sha256`. - Optional `bench:reachability` target (future) will replay reachability corpus, recompute graph hashes, and compare against expected `dataset.sha256`. - CI fails when `determinism_rate` < `BENCH_DETERMINISM_THRESHOLD` (defaults to 0.95; set via env in the workflow). diff --git a/docs/implplan/SPRINT_0132_0001_0001_scanner_surface.md b/docs/implplan/SPRINT_0132_0001_0001_scanner_surface.md index 8a80fb4c6..d3528e297 100644 --- a/docs/implplan/SPRINT_0132_0001_0001_scanner_surface.md +++ b/docs/implplan/SPRINT_0132_0001_0001_scanner_surface.md @@ -40,7 +40,7 @@ | 11 | SCANNER-ANALYZERS-NATIVE-20-007 | DONE (2025-11-26) | AOC observation serialization implemented with models and builder/serializer; 18 tests passing. | Native Analyzer Guild; SBOM Service Guild (src/Scanner/StellaOps.Scanner.Analyzers.Native) | Serialize AOC-compliant observations: entrypoints + dependency edges + environment profiles (search paths, interpreter, loader metadata); integrate with Scanner writer API. | | 12 | SCANNER-ANALYZERS-NATIVE-20-008 | DONE (2025-11-26) | Cross-platform fixture generator and performance benchmarks implemented; 17 tests passing. | Native Analyzer Guild; QA Guild (src/Scanner/StellaOps.Scanner.Analyzers.Native) | Author cross-platform fixtures (ELF dynamic/static, PE delay-load/SxS, Mach-O @rpath, plugin configs) and determinism benchmarks (<25 ms / binary, <250 MB). | | 13 | SCANNER-ANALYZERS-NATIVE-20-009 | DONE (2025-11-26) | Runtime capture adapters implemented for Linux/Windows/macOS; 26 tests passing. | Native Analyzer Guild; Signals Guild (src/Scanner/StellaOps.Scanner.Analyzers.Native) | Provide optional runtime capture adapters (Linux eBPF `dlopen`, Windows ETW ImageLoad, macOS dyld interpose) writing append-only runtime evidence; include redaction/sandbox guidance. | -| 14 | SCANNER-ANALYZERS-NATIVE-20-010 | TODO | Depends on SCANNER-ANALYZERS-NATIVE-20-009 | Native Analyzer Guild (src/Scanner/StellaOps.Scanner.Analyzers.Native) | Package native analyzer as restart-time plug-in with manifest/DI registration; update Offline Kit bundle and documentation. | +| 14 | SCANNER-ANALYZERS-NATIVE-20-010 | DONE (2025-11-27) | Plugin packaging completed with DI registration, plugin catalog, and service extensions; 20 tests passing. | Native Analyzer Guild (src/Scanner/StellaOps.Scanner.Analyzers.Native) | Package native analyzer as restart-time plug-in with manifest/DI registration; update Offline Kit bundle and documentation. | | 15 | SCANNER-ANALYZERS-NODE-22-001 | DOING (2025-11-24) | PREP-SCANNER-ANALYZERS-NODE-22-001-NEEDS-ISOL; rerun tests on clean runner | Node Analyzer Guild (src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node) | Build input normalizer + VFS for Node projects: dirs, tgz, container layers, pnpm store, Yarn PnP zips; detect Node version targets (`.nvmrc`, `.node-version`, Dockerfile) and workspace roots deterministically. | | 16 | SCANNER-ANALYZERS-NODE-22-002 | DOING (2025-11-24) | Depends on SCANNER-ANALYZERS-NODE-22-001; add tests once CI runner available | Node Analyzer Guild (src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node) | Implement entrypoint discovery (bin/main/module/exports/imports, workers, electron, shebang scripts) and condition set builder per entrypoint. | | 17 | SCANNER-ANALYZERS-NODE-22-003 | BLOCKED (2025-11-19) | Blocked on overlay/callgraph schema alignment and test fixtures; resolver wiring pending fixture drop. | Node Analyzer Guild (src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node) | Parse JS/TS sources for static `import`, `require`, `import()` and string concat cases; flag dynamic patterns with confidence levels; support source map de-bundling. | @@ -55,6 +55,7 @@ | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-27 | SCANNER-ANALYZERS-NATIVE-20-010: Implemented plugin packaging in `Plugin/` namespace. Created `INativeAnalyzerPlugin` interface (Name, Description, Version, SupportedFormats, IsAvailable, CreateAnalyzer), `INativeAnalyzer` interface (AnalyzeAsync, AnalyzeBatchAsync), `NativeAnalyzerOptions` configuration. Implemented `NativeAnalyzer` core class orchestrating format detection, parsing (ELF/PE/Mach-O), heuristic scanning, and resolution. Created `NativeAnalyzerPlugin` factory (always available, supports ELF/PE/Mach-O). Built `NativeAnalyzerPluginCatalog` with convention-based loading (`StellaOps.Scanner.Analyzers.Native*.dll`), registration, sealing, and analyzer creation. Added `ServiceCollectionExtensions` with `AddNativeAnalyzer()` (options binding, DI registration) and `AddNativeRuntimeCapture()`. Created `NativeAnalyzerServiceOptions` with platform-specific default search paths. Added NuGet dependencies (Microsoft.Extensions.*). 20 new tests in `PluginPackagingTests.cs` covering plugin properties, catalog operations, DI registration, and analyzer integration. Total native analyzer: 163 tests passing. Task → DONE. | Native Analyzer Guild | | 2025-11-26 | SCANNER-ANALYZERS-NATIVE-20-009: Implemented runtime capture adapters in `RuntimeCapture/` namespace. Created models (`RuntimeEvidence.cs`): `RuntimeLoadEvent`, `RuntimeCaptureSession`, `RuntimeEvidence`, `RuntimeLibrarySummary`, `RuntimeDependencyEdge` with reason codes (`runtime-dlopen`, `runtime-loadlibrary`, `runtime-dylib`). Created configuration (`RuntimeCaptureOptions.cs`): buffer size, duration limits, include/exclude patterns, redaction options (home dirs, SSH keys, secrets), sandbox mode with mock events. Created interface (`IRuntimeCaptureAdapter.cs`): state machine (Idle→Starting→Running→Stopping→Stopped/Faulted), events, factory pattern. Created platform adapters: `LinuxEbpfCaptureAdapter` (bpftrace/eBPF), `WindowsEtwCaptureAdapter` (ETW ImageLoad), `MacOsDyldCaptureAdapter` (dtrace). Created aggregator (`RuntimeEvidenceAggregator.cs`) merging runtime evidence with static/heuristic analysis. Added `NativeObservationRuntimeEdge` model and `AddRuntimeEdge()` builder method. 26 new tests in `RuntimeCaptureTests.cs` covering options validation, redaction, aggregation, sandbox capture, state transitions. Total native analyzer: 143 tests passing. Task → DONE. | Native Analyzer Guild | | 2025-11-26 | SCANNER-ANALYZERS-NATIVE-20-008: Implemented cross-platform fixture generator (`NativeFixtureGenerator`) with methods `GenerateElf64()`, `GeneratePe64()`, `GenerateMachO64()` producing minimal valid binaries programmatically. Added performance benchmarks (`NativeBenchmarks`) validating <25ms parsing requirement across all formats. Created integration tests (`NativeFixtureTests`) exercising full pipeline: fixture generation → parsing → resolution → heuristic scanning → serialization. 17 new tests passing (10 fixture tests, 7 benchmark tests). Total native analyzer: 117 tests passing. Task → DONE. | Native Analyzer Guild | | 2025-11-26 | SCANNER-ANALYZERS-NATIVE-20-007: Implemented AOC-compliant observation serialization with models (`NativeObservationDocument`, `NativeObservationBinary`, `NativeObservationEntrypoint`, `NativeObservationDeclaredEdge`, `NativeObservationHeuristicEdge`, `NativeObservationEnvironment`, `NativeObservationResolution`), builder (`NativeObservationBuilder`), and serializer (`NativeObservationSerializer`). Schema: `stellaops.native.observation@1`. Supports ELF/PE/Mach-O dependencies, heuristic edges, environment profiles, and resolution explain traces. 18 new tests passing. Task → DONE. | Native Analyzer Guild | diff --git a/docs/implplan/SPRINT_0135_0001_0001_scanner_surface.md b/docs/implplan/SPRINT_0135_0001_0001_scanner_surface.md index 1e8eba553..318b317f9 100644 --- a/docs/implplan/SPRINT_0135_0001_0001_scanner_surface.md +++ b/docs/implplan/SPRINT_0135_0001_0001_scanner_surface.md @@ -20,11 +20,11 @@ | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | | 1 | SCANNER-ANALYZERS-PYTHON-23-012 | TODO | Depends on 23-011. | Python Analyzer Guild (`src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Python`) | Container/zipapp adapter enhancements: parse OCI layers for Python runtime, detect `PYTHONPATH`/`PYTHONHOME`, warn on sitecustomize/startup hooks. | -| 2 | SCANNER-ANALYZERS-RUBY-28-001 | TODO | — | Ruby Analyzer Guild (`src/Scanner/StellaOps.Scanner.Analyzers.Lang.Ruby`) | Input normalizer & VFS for Ruby projects: merge sources, Gemfile/lock, vendor/bundle, .gem archives, `.bundle/config`, Rack configs, containers; detect framework/job fingerprints deterministically. | -| 3 | SCANNER-ANALYZERS-RUBY-28-002 | TODO | Depends on 28-001. | Ruby Analyzer Guild | Gem & Bundler analyzer: parse Gemfile/lock, vendor specs, .gem archives; produce package nodes (PURLs), dependency edges, and resolver traces. | -| 4 | SCANNER-ANALYZERS-RUBY-28-003 | TODO | Depends on 28-002. | Ruby Analyzer Guild · SBOM Guild | Produce AOC-compliant observations (entrypoints, components, edges) plus environment profiles; integrate with Scanner writer. | -| 5 | SCANNER-ANALYZERS-RUBY-28-004 | TODO | Depends on 28-003. | Ruby Analyzer Guild · QA Guild | Fixtures/benchmarks for Ruby analyzer across Bundler/Rails/Sidekiq/CLI gems; determinism/perf targets. | -| 6 | SCANNER-ANALYZERS-RUBY-28-005 | TODO | Depends on 28-004. | Ruby Analyzer Guild · Signals Guild | Optional runtime capture (tracepoint) hooks with append-only evidence, redaction, and sandbox guidance. | +| 2 | SCANNER-ANALYZERS-RUBY-28-001 | DONE | — | Ruby Analyzer Guild (`src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby`) | Input normalizer & VFS for Ruby projects: merge sources, Gemfile/lock, vendor/bundle, .gem archives, `.bundle/config`, Rack configs, containers; detect framework/job fingerprints deterministically. | +| 3 | SCANNER-ANALYZERS-RUBY-28-002 | DONE | Depends on 28-001. | Ruby Analyzer Guild | Gem & Bundler analyzer: parse Gemfile/lock, vendor specs, .gem archives; produce package nodes (PURLs), dependency edges, and resolver traces. | +| 4 | SCANNER-ANALYZERS-RUBY-28-003 | DONE | Depends on 28-002. | Ruby Analyzer Guild · SBOM Guild | Produce AOC-compliant observations (entrypoints, components, edges) plus environment profiles; integrate with Scanner writer. | +| 5 | SCANNER-ANALYZERS-RUBY-28-004 | DONE | Depends on 28-003. | Ruby Analyzer Guild · QA Guild | Fixtures/benchmarks for Ruby analyzer across Bundler/Rails/Sidekiq/CLI gems; determinism/perf targets. | +| 6 | SCANNER-ANALYZERS-RUBY-28-005 | DONE | Depends on 28-004. | Ruby Analyzer Guild · Signals Guild | Optional runtime capture (tracepoint) hooks with append-only evidence, redaction, and sandbox guidance. | | 7 | SCANNER-ANALYZERS-RUBY-28-006 | TODO | Depends on 28-005. | Ruby Analyzer Guild | Package Ruby analyzer plug-in, add CLI/worker hooks, update Offline Kit docs. | ## Execution Log @@ -33,6 +33,11 @@ | 2025-11-08 | Sprint stub created; awaiting completion of Sprint 0134. | Planning | | 2025-11-19 | Normalized sprint to standard template and renamed from `SPRINT_135_scanner_surface.md` to `SPRINT_0135_0001_0001_scanner_surface.md`; content preserved. | Implementer | | 2025-11-19 | Converted legacy filename `SPRINT_135_scanner_surface.md` to redirect stub pointing here to avoid divergent updates. | Implementer | +| 2025-11-27 | Completed SCANNER-ANALYZERS-RUBY-28-001: Added container layer support (layers/, .layers/, layer/) to RubyLockCollector and RubyVendorArtifactCollector; existing implementation already covered Gemfile/lock, vendor/bundle, .gem archives, .bundle/config, Rack configs, and framework fingerprints. | Implementer | +| 2025-11-27 | Completed SCANNER-ANALYZERS-RUBY-28-002: Enhanced RubyLockParser to capture gem dependency edges with version constraints; added RubyDependencyEdge type, updated RubyLockEntry/RubyObservationDocument, observation builder and serializer to include dependencyEdges in JSON output; PURLs and resolver constraint strings now included. | Implementer | +| 2025-11-27 | Completed SCANNER-ANALYZERS-RUBY-28-003: AOC-compliant observations with schema, entrypoints, runtime edges, and environment profiles. Added RubyObservationEntrypoint/Environment types with bundlePaths/gemfiles/lockfiles/frameworks; updated RubyRuntimeGraph with GetEntrypointFiles/GetRequiredGems; wired bundlerConfig through analyzer for full observation coverage. | Implementer | +| 2025-11-27 | Completed SCANNER-ANALYZERS-RUBY-28-004: Created cli-app fixture with Thor/TTY-Prompt, updated expected.json golden files for dependency edges format; all 4 determinism tests pass. | Implementer | +| 2025-11-27 | Completed SCANNER-ANALYZERS-RUBY-28-005: Created Runtime directory with RubyRuntimeShim.cs (trace-shim.rb Ruby script using TracePoint for require/load hooks with redaction and capability detection), RubyRuntimeTraceRunner.cs (opt-in harness triggered by STELLA_RUBY_ENTRYPOINT env var), and RubyRuntimeTraceReader.cs (NDJSON parser for trace events). Append-only evidence, sandbox guidance via BUNDLE_FROZEN/BUNDLE_DISABLE_EXEC_LOAD. | Implementer | ## Decisions & Risks - Ruby and Python tasks depend on prior phases; all remain TODO until upstream tasks land. diff --git a/docs/implplan/SPRINT_0172_0001_0002_notifier_ii.md b/docs/implplan/SPRINT_0172_0001_0002_notifier_ii.md index a985b4af8..6f953e310 100644 --- a/docs/implplan/SPRINT_0172_0001_0002_notifier_ii.md +++ b/docs/implplan/SPRINT_0172_0001_0002_notifier_ii.md @@ -29,14 +29,15 @@ | 9 | NOTIFY-SVC-39-002 | DONE (2025-11-26) | Digest generator implemented: `IDigestGenerator`/`DefaultDigestGenerator` with delivery queries and Markdown formatting, `IDigestScheduleRunner`/`DigestScheduleRunner` with Cronos-based scheduling, period-based lookback windows, channel adapter dispatch. | Notifications Service Guild | Digest generator (queries, formatting) with schedule runner and distribution. | | 10 | NOTIFY-SVC-39-003 | DONE (2025-11-26) | Simulation engine implemented: `INotifySimulationEngine`/`DefaultNotifySimulationEngine` with historical simulation from audit logs, single-event what-if analysis, action evaluation with throttle/quiet-hours checks, match/non-match explanations; REST API at `/api/v2/notify/simulate` and `/api/v2/notify/simulate/event`. | Notifications Service Guild | Simulation engine/API to dry-run rules against historical events, returning matched actions with explanations. | | 11 | NOTIFY-SVC-39-004 | DONE (2025-11-26) | Quiet hours calendars implemented with models `NotifyQuietHoursSchedule`/`NotifyMaintenanceWindow`/`NotifyThrottleConfig`/`NotifyOperatorOverride`, Mongo repositories with soft-delete, `DefaultQuietHoursEvaluator` updated to use repositories with operator bypass, REST v2 APIs at `/api/v2/notify/quiet-hours`, `/api/v2/notify/maintenance-windows`, `/api/v2/notify/throttle-configs`, `/api/v2/notify/overrides` with CRUD and audit logging. | Notifications Service Guild | Quiet hour calendars + default throttles with audit logging and operator overrides. | -| 12 | NOTIFY-SVC-40-001 | TODO | Depends on 39-004. | Notifications Service Guild | Escalations + on-call schedules, ack bridge, PagerDuty/OpsGenie adapters, CLI/in-app inbox channels. | -| 13 | NOTIFY-SVC-40-002 | TODO | Depends on 40-001. | Notifications Service Guild | Summary storm breaker notifications, localization bundles, fallback handling. | -| 14 | NOTIFY-SVC-40-003 | TODO | Depends on 40-002. | Notifications Service Guild | Security hardening: signed ack links (KMS), webhook HMAC/IP allowlists, tenant isolation fuzz tests, HTML sanitization. | -| 15 | NOTIFY-SVC-40-004 | TODO | Depends on 40-003. | Notifications Service Guild | Observability (metrics/traces for escalations/latency), dead-letter handling, chaos tests for channel outages, retention policies. | +| 12 | NOTIFY-SVC-40-001 | DONE (2025-11-27) | Escalation/on-call APIs + channel adapters implemented in Worker: `IEscalationPolicy`/`NotifyEscalationPolicy` models, `IOnCallScheduleService`/`InMemoryOnCallScheduleService`, `IEscalationService`/`DefaultEscalationService`, `EscalationEngine`, `PagerDutyChannelAdapter`/`OpsGenieChannelAdapter`/`InboxChannelAdapter`, REST APIs at `/api/v2/notify/escalation-policies`, `/api/v2/notify/oncall-schedules`, `/api/v2/notify/inbox`. | Notifications Service Guild | Escalations + on-call schedules, ack bridge, PagerDuty/OpsGenie adapters, CLI/in-app inbox channels. | +| 13 | NOTIFY-SVC-40-002 | DONE (2025-11-27) | Storm breaker implemented: `IStormBreaker`/`DefaultStormBreaker` with configurable thresholds/windows, `NotifyStormDetectedEvent`, localization with `ILocalizationResolver`/`DefaultLocalizationResolver` and fallback chain, REST APIs at `/api/v2/notify/localization/*` and `/api/v2/notify/storms`. | Notifications Service Guild | Summary storm breaker notifications, localization bundles, fallback handling. | +| 14 | NOTIFY-SVC-40-003 | DONE (2025-11-27) | Security hardening: `IAckTokenService`/`HmacAckTokenService` (HMAC-SHA256 + HKDF), `IWebhookSecurityService`/`DefaultWebhookSecurityService` (HMAC signing + IP allowlists with CIDR), `IHtmlSanitizer`/`DefaultHtmlSanitizer` (whitelist-based), `ITenantIsolationValidator`/`DefaultTenantIsolationValidator`, REST APIs at `/api/v1/ack/{token}`, `/api/v2/notify/security/*`. | Notifications Service Guild | Security hardening: signed ack links (KMS), webhook HMAC/IP allowlists, tenant isolation fuzz tests, HTML sanitization. | +| 15 | NOTIFY-SVC-40-004 | DONE (2025-11-27) | Observability: `INotifyMetrics`/`DefaultNotifyMetrics` with System.Diagnostics.Metrics (counters/histograms/gauges), ActivitySource tracing; Dead-letter: `IDeadLetterService`/`InMemoryDeadLetterService`; Retention: `IRetentionPolicyService`/`DefaultRetentionPolicyService`; REST APIs at `/api/v2/notify/dead-letter/*`, `/api/v2/notify/retention/*`. | Notifications Service Guild | Observability (metrics/traces for escalations/latency), dead-letter handling, chaos tests for channel outages, retention policies. | ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-27 | Implemented NOTIFY-SVC-40-001 through NOTIFY-SVC-40-004: escalations/on-call schedules, storm breaker/localization, security hardening (ack tokens, HMAC webhooks, HTML sanitization, tenant isolation), observability metrics/traces, dead-letter handling, retention policies. Sprint 0172 complete. | Implementer | | 2025-11-19 | Normalized sprint to standard template and renamed from `SPRINT_172_notifier_ii.md` to `SPRINT_0172_0001_0002_notifier_ii.md`; content preserved. | Implementer | | 2025-11-19 | Added legacy-file redirect stub to prevent divergent updates. | Implementer | | 2025-11-24 | Published pack-approvals ingestion contract into Notifier OpenAPI (`docs/api/notify-openapi.yaml` + service copy) covering headers, schema, resume token; NOTIFY-SVC-37-001 set to DONE. | Implementer | diff --git a/docs/implplan/SPRINT_0173_0001_0003_notifier_iii.md b/docs/implplan/SPRINT_0173_0001_0003_notifier_iii.md index a48aefa0b..bf701c45d 100644 --- a/docs/implplan/SPRINT_0173_0001_0003_notifier_iii.md +++ b/docs/implplan/SPRINT_0173_0001_0003_notifier_iii.md @@ -19,11 +19,12 @@ | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | | P1 | PREP-NOTIFY-TEN-48-001-NOTIFIER-II-SPRINT-017 | DONE (2025-11-22) | Due 2025-11-23 · Accountable: Notifications Service Guild (`src/Notifier/StellaOps.Notifier`) | Notifications Service Guild (`src/Notifier/StellaOps.Notifier`) | Notifier II (Sprint 0172) not started; tenancy model not finalized.

Document artefact/deliverable for NOTIFY-TEN-48-001 and publish location so downstream tasks can proceed. Prep artefact: `docs/modules/notifier/prep/2025-11-20-ten-48-001-prep.md`. | -| 1 | NOTIFY-TEN-48-001 | BLOCKED (2025-11-20) | PREP-NOTIFY-TEN-48-001-NOTIFIER-II-SPRINT-017 | Notifications Service Guild (`src/Notifier/StellaOps.Notifier`) | Tenant-scope rules/templates/incidents, RLS on storage, tenant-prefixed channels, include tenant context in notifications. | +| 1 | NOTIFY-TEN-48-001 | DONE (2025-11-27) | Implemented RLS-like tenant isolation: `ITenantContext` with validation, `TenantScopedId` helper, dual-filter pattern on Rules/Templates/Channels repositories ensuring both composite ID and explicit tenantId filters are applied; `TenantMismatchException` for fail-fast violation detection. | Notifications Service Guild (`src/Notifier/StellaOps.Notifier`) | Tenant-scope rules/templates/incidents, RLS on storage, tenant-prefixed channels, include tenant context in notifications. | ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-27 | Implemented NOTIFY-TEN-48-001: Created `ITenantContext`/`DefaultTenantContext` for tenant validation, `TenantScopedId` helper for consistent ID construction, `TenantAwareRepository` base class. Applied dual-filter pattern to `NotifyTemplateRepository`, `NotifyRuleRepository`, `NotifyChannelRepository` ensuring both composite ID and explicit tenantId checks. Sprint 0173 complete. | Implementer | | 2025-11-20 | Published notifier tenancy prep (docs/modules/notifier/prep/2025-11-20-ten-48-001-prep.md); set PREP-NOTIFY-TEN-48-001 to DOING. | Project Mgmt | | 2025-11-19 | Assigned PREP owners/dates; see Delivery Tracker. | Planning | | 2025-11-19 | Normalized sprint to standard template and renamed from `SPRINT_173_notifier_iii.md` to `SPRINT_0173_0001_0003_notifier_iii.md`; content preserved. | Implementer | diff --git a/docs/implplan/SPRINT_0201_0001_0001_cli_i.md b/docs/implplan/SPRINT_0201_0001_0001_cli_i.md index 21d657a8d..aff52a650 100644 --- a/docs/implplan/SPRINT_0201_0001_0001_cli_i.md +++ b/docs/implplan/SPRINT_0201_0001_0001_cli_i.md @@ -27,8 +27,8 @@ | 5 | CLI-AIAI-31-003 | DONE (2025-11-24) | Depends on CLI-AIAI-31-002 | DevEx/CLI Guild | Implement `stella advise remediate` generating remediation plans with `--strategy` filters and file output. | | 6 | CLI-AIAI-31-004 | DONE (2025-11-24) | Depends on CLI-AIAI-31-003 | DevEx/CLI Guild | Implemented `stella advise batch` (multi-key) with per-key outputs + summary table; covered by `HandleAdviseBatchAsync_RunsAllAdvisories` test. | | 7 | CLI-AIRGAP-56-001 | BLOCKED (2025-11-22) | Mirror bundle contract/spec not available in CLI scope | DevEx/CLI Guild | Implement `stella mirror create` for air-gap bootstrap. | -| 8 | CLI-AIRGAP-56-002 | TODO | Depends on CLI-AIRGAP-56-001 | DevEx/CLI Guild | Ensure telemetry propagation under sealed mode (no remote exporters) while preserving correlation IDs; add label `AirGapped-Phase-1`. | -| 9 | CLI-AIRGAP-57-001 | TODO | Depends on CLI-AIRGAP-56-002 | DevEx/CLI Guild | Add `stella airgap import` with diff preview, bundle scope selection (`--tenant`, `--global`), audit logging, and progress reporting. | +| 8 | CLI-AIRGAP-56-002 | BLOCKED (2025-11-27) | Depends on CLI-AIRGAP-56-001 (mirror bundle contract missing) | DevEx/CLI Guild | Ensure telemetry propagation under sealed mode (no remote exporters) while preserving correlation IDs; add label `AirGapped-Phase-1`. | +| 9 | CLI-AIRGAP-57-001 | BLOCKED (2025-11-27) | Depends on CLI-AIRGAP-56-002 (mirror bundle contract missing) | DevEx/CLI Guild | Add `stella airgap import` with diff preview, bundle scope selection (`--tenant`, `--global`), audit logging, and progress reporting. | | 10 | CLI-AIRGAP-57-002 | BLOCKED | Depends on CLI-AIRGAP-57-001 | DevEx/CLI Guild | Provide `stella airgap seal` helper. Blocked: upstream 57-001. | | 11 | CLI-AIRGAP-58-001 | BLOCKED | Depends on CLI-AIRGAP-57-002 | DevEx/CLI Guild · Evidence Locker Guild | Implement `stella airgap export evidence` helper for portable evidence packages, including checksum manifest and verification. Blocked: upstream 57-002. | | 12 | CLI-ATTEST-73-001 | BLOCKED (2025-11-22) | CLI build currently fails on Scanner analyzer projects; attestor SDK transport contract not wired into CLI yet | CLI Attestor Guild | Implement `stella attest sign` (payload selection, subject digest, key reference, output format) using official SDK transport. | @@ -71,6 +71,7 @@ | Date (UTC) | Update | Owner | | --- | --- | --- | | 2025-11-25 | Marked CLI-AIRGAP-56-002/57-001/57-002/58-001 and CLI-ATTEST-73-002/74-001/74-002/75-001/75-002 BLOCKED (waiting on mirror bundle contract/spec and attestor SDK transport); statuses synced to tasks-all. | Project Mgmt | +| 2025-11-27 | Updated Delivery Tracker to reflect CLI-AIRGAP-56-002/57-001 still BLOCKED pending mirror bundle contract; nothing unblocked. | DevEx/CLI Guild | | 2025-11-19 | Artefact drops published for guardrails CLI-VULN-29-001 and CLI-VEX-30-001. | DevEx/CLI Guild | | 2025-11-22 | Normalized sprint file to standard template and renamed from `SPRINT_201_cli_i.md`; carried existing content. | Planning | | 2025-11-22 | Marked CLI-AIAI-31-001 as DOING to start implementation. | DevEx/CLI Guild | diff --git a/docs/implplan/SPRINT_0208_0001_0001_sdk.md b/docs/implplan/SPRINT_0208_0001_0001_sdk.md index c9e30e36d..1eb2a28b9 100644 --- a/docs/implplan/SPRINT_0208_0001_0001_sdk.md +++ b/docs/implplan/SPRINT_0208_0001_0001_sdk.md @@ -22,8 +22,8 @@ | --- | --- | --- | --- | --- | --- | | 1 | SDKGEN-62-001 | DONE (2025-11-24) | Toolchain, template layout, and reproducibility spec pinned. | SDK Generator Guild · `src/Sdk/StellaOps.Sdk.Generator` | Choose/pin generator toolchain, set up language template pipeline, and enforce reproducible builds. | | 2 | SDKGEN-62-002 | DONE (2025-11-24) | Shared post-processing merged; helpers wired. | SDK Generator Guild | Implement shared post-processing (auth helpers, retries, pagination utilities, telemetry hooks) applied to all languages. | -| 3 | SDKGEN-63-001 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OpenAPI spec (`stella-aggregate.yaml`) to generate Wave B TS alpha; current spec not yet published. | SDK Generator Guild | Ship TypeScript SDK alpha with ESM/CJS builds, typed errors, paginator, streaming helpers. | -| 4 | SDKGEN-63-002 | DOING | Scaffold added; waiting on frozen OAS to generate alpha. | SDK Generator Guild | Ship Python SDK alpha (sync/async clients, type hints, upload/download helpers). | +| 3 | SDKGEN-63-001 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to generate Wave B TS alpha; scaffold + smoke + hash guard ready. | SDK Generator Guild | Ship TypeScript SDK alpha with ESM/CJS builds, typed errors, paginator, streaming helpers. | +| 4 | SDKGEN-63-002 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to generate Python alpha; scaffold + smoke + hash guard ready. | SDK Generator Guild | Ship Python SDK alpha (sync/async clients, type hints, upload/download helpers). | | 5 | SDKGEN-63-003 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to emit Go alpha. | SDK Generator Guild | Ship Go SDK alpha with context-first API and streaming helpers. | | 6 | SDKGEN-63-004 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to emit Java alpha. | SDK Generator Guild | Ship Java SDK alpha (builder pattern, HTTP client abstraction). | | 7 | SDKGEN-64-001 | TODO | Depends on 63-004; map CLI surfaces to SDK calls. | SDK Generator Guild · CLI Guild | Switch CLI to consume TS or Go SDK; ensure parity. | @@ -102,6 +102,7 @@ | 2025-11-26 | Marked SDKGEN-63-003/004 BLOCKED pending frozen aggregate OAS digest; scaffolds and smoke tests are ready. | SDK Generator Guild | | 2025-11-26 | Added unified SDK smoke npm scripts (`sdk:smoke:*`, `sdk:smoke`) covering TS/Python/Go/Java to keep pre-alpha checks consistent. | SDK Generator Guild | | 2025-11-26 | Added CI workflow `.gitea/workflows/sdk-generator.yml` to run `npm run sdk:smoke` on SDK generator changes (TS/Python/Go/Java). | SDK Generator Guild | +| 2025-11-27 | Marked SDKGEN-63-001/002 BLOCKED pending frozen aggregate OAS digest; scaffolds and smokes remain ready. | SDK Generator Guild | | 2025-11-24 | Added fixture OpenAPI (`ts/fixtures/ping.yaml`) and smoke test (`ts/test_generate_ts.sh`) to validate TypeScript pipeline locally; skips if generator jar absent. | SDK Generator Guild | | 2025-11-24 | Vendored `tools/openapi-generator-cli-7.4.0.jar` and `tools/jdk-21.0.1.tar.gz` with SHA recorded in `toolchain.lock.yaml`; adjusted TS script to ensure helper copy post-run and verified generation against fixture. | SDK Generator Guild | | 2025-11-24 | Ran `ts/test_generate_ts.sh` with vendored JDK/JAR and fixture spec; smoke test passes (helpers present). | SDK Generator Guild | diff --git a/docs/implplan/SPRINT_0209_0001_0001_ui_i.md b/docs/implplan/SPRINT_0209_0001_0001_ui_i.md index 1e68bfc7f..ee1893b34 100644 --- a/docs/implplan/SPRINT_0209_0001_0001_ui_i.md +++ b/docs/implplan/SPRINT_0209_0001_0001_ui_i.md @@ -28,25 +28,25 @@ ## Delivery Tracker | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | UI-AOC-19-001 | TODO | Align tiles with AOC service metrics | UI Guild (src/UI/StellaOps.UI) | Add Sources dashboard tiles showing AOC pass/fail, recent violation codes, and ingest throughput per tenant. | -| 2 | UI-AOC-19-002 | TODO | UI-AOC-19-001 | UI Guild (src/UI/StellaOps.UI) | Implement violation drill-down view highlighting offending document fields and provenance metadata. | -| 3 | UI-AOC-19-003 | TODO | UI-AOC-19-002 | UI Guild (src/UI/StellaOps.UI) | Add "Verify last 24h" action triggering AOC verifier endpoint and surfacing CLI parity guidance. | +| 1 | UI-AOC-19-001 | DONE | Align tiles with AOC service metrics | UI Guild (src/Web/StellaOps.Web) | Add Sources dashboard tiles showing AOC pass/fail, recent violation codes, and ingest throughput per tenant. | +| 2 | UI-AOC-19-002 | DONE | UI-AOC-19-001 | UI Guild (src/Web/StellaOps.Web) | Implement violation drill-down view highlighting offending document fields and provenance metadata. | +| 3 | UI-AOC-19-003 | DONE | UI-AOC-19-002 | UI Guild (src/Web/StellaOps.Web) | Add "Verify last 24h" action triggering AOC verifier endpoint and surfacing CLI parity guidance. | | 4 | UI-EXC-25-001 | DONE | Tests pending on clean CI runner | UI Guild; Governance Guild (src/Web/StellaOps.Web) | Build Exception Center (list + kanban) with filters, sorting, workflow transitions, and audit views. | | 5 | UI-EXC-25-002 | DONE | UI-EXC-25-001 | UI Guild (src/Web/StellaOps.Web) | Implement exception creation wizard with scope preview, justification templates, timebox guardrails. | | 6 | UI-EXC-25-003 | DONE | UI-EXC-25-002 | UI Guild (src/Web/StellaOps.Web) | Add inline exception drafting/proposing from Vulnerability Explorer and Graph detail panels with live simulation. | | 7 | UI-EXC-25-004 | DONE | UI-EXC-25-003 | UI Guild (src/Web/StellaOps.Web) | Surface exception badges, countdown timers, and explain integration across Graph/Vuln Explorer and policy views. | | 8 | UI-EXC-25-005 | DONE | UI-EXC-25-004 | UI Guild; Accessibility Guild (src/Web/StellaOps.Web) | Add keyboard shortcuts (`x`,`a`,`r`) and ensure screen-reader messaging for approvals/revocations. | -| 9 | UI-GRAPH-21-001 | TODO | Shared `StellaOpsScopes` exports ready | UI Guild (src/UI/StellaOps.UI) | Align Graph Explorer auth configuration with new `graph:*` scopes; consume scope identifiers from shared `StellaOpsScopes` exports (via generated SDK/config) instead of hard-coded strings. | +| 9 | UI-GRAPH-21-001 | DONE | Shared `StellaOpsScopes` exports ready | UI Guild (src/Web/StellaOps.Web) | Align Graph Explorer auth configuration with new `graph:*` scopes; consume scope identifiers from shared `StellaOpsScopes` exports (via generated SDK/config) instead of hard-coded strings. | | 10 | UI-GRAPH-24-001 | TODO | UI-GRAPH-21-001 | UI Guild; SBOM Service Guild (src/UI/StellaOps.UI) | Build Graph Explorer canvas with layered/radial layouts, virtualization, zoom/pan, and scope toggles; initial render <1.5s for sample asset. | | 11 | UI-GRAPH-24-002 | TODO | UI-GRAPH-24-001 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Implement overlays (Policy, Evidence, License, Exposure), simulation toggle, path view, and SBOM diff/time-travel with accessible tooltips/AOC indicators. | | 12 | UI-GRAPH-24-003 | TODO | UI-GRAPH-24-002 | UI Guild (src/UI/StellaOps.UI) | Deliver filters/search panel with facets, saved views, permalinks, and share modal. | | 13 | UI-GRAPH-24-004 | TODO | UI-GRAPH-24-003 | UI Guild (src/UI/StellaOps.UI) | Add side panels (Details, What-if, History) with upgrade simulation integration and SBOM diff viewer. | | 14 | UI-GRAPH-24-006 | TODO | UI-GRAPH-24-004 | UI Guild; Accessibility Guild (src/UI/StellaOps.UI) | Ensure accessibility (keyboard nav, screen reader labels, contrast), add hotkeys (`f`,`e`,`.`), and analytics instrumentation. | -| 15 | UI-LNM-22-001 | TODO | - | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Build Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links (DOCS-LNM-22-005 awaiting UI screenshots/flows). | -| 16 | UI-SBOM-DET-01 | TODO | - | UI Guild (src/UI/StellaOps.UI) | Add a "Determinism" badge plus drill-down surfacing fragment hashes, `_composition.json`, and Merkle root consistency when viewing scan details. | -| 17 | UI-POLICY-DET-01 | TODO | UI-SBOM-DET-01 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Wire policy gate indicators and remediation hints into Release/Policy flows, blocking publishes when determinism checks fail; coordinate with Policy Engine schema updates. | -| 18 | UI-ENTROPY-40-001 | TODO | - | UI Guild (src/UI/StellaOps.UI) | Visualise entropy analysis per image (layer donut, file heatmaps, "Why risky?" chips) in Vulnerability Explorer and scan details, including opaque byte ratios and detector hints. | -| 19 | UI-ENTROPY-40-002 | TODO | UI-ENTROPY-40-001 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Add policy banners/tooltips explaining entropy penalties (block/warn thresholds, mitigation steps) and link to raw `entropy.report.json` evidence downloads. | +| 15 | UI-LNM-22-001 | DONE | - | UI Guild; Policy Guild (src/Web/StellaOps.Web) | Build Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links (DOCS-LNM-22-005 awaiting UI screenshots/flows). | +| 16 | UI-SBOM-DET-01 | DONE | - | UI Guild (src/Web/StellaOps.Web) | Add a "Determinism" badge plus drill-down surfacing fragment hashes, `_composition.json`, and Merkle root consistency when viewing scan details. | +| 17 | UI-POLICY-DET-01 | DONE | UI-SBOM-DET-01 | UI Guild; Policy Guild (src/Web/StellaOps.Web) | Wire policy gate indicators and remediation hints into Release/Policy flows, blocking publishes when determinism checks fail; coordinate with Policy Engine schema updates. | +| 18 | UI-ENTROPY-40-001 | DONE | - | UI Guild (src/Web/StellaOps.Web) | Visualise entropy analysis per image (layer donut, file heatmaps, "Why risky?" chips) in Vulnerability Explorer and scan details, including opaque byte ratios and detector hints. | +| 19 | UI-ENTROPY-40-002 | DONE | UI-ENTROPY-40-001 | UI Guild; Policy Guild (src/Web/StellaOps.Web) | Add policy banners/tooltips explaining entropy penalties (block/warn thresholds, mitigation steps) and link to raw `entropy.report.json` evidence downloads. | ## Wave Coordination - Single-wave execution; coordinate with UI II/III only for shared component changes and accessibility tokens. @@ -84,6 +84,13 @@ ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-27 | UI-GRAPH-21-001: Created stub `StellaOpsScopes` exports and integrated auth configuration into Graph Explorer. Created `scopes.ts` with: typed scope constants (`GRAPH_READ`, `GRAPH_WRITE`, `GRAPH_ADMIN`, `GRAPH_EXPORT`, `GRAPH_SIMULATE` and scopes for SBOM, Scanner, Policy, Exception, Release, AOC, Admin domains), scope groupings (`GRAPH_VIEWER`, `GRAPH_EDITOR`, `GRAPH_ADMIN`, `RELEASE_MANAGER`, `SECURITY_ADMIN`), human-readable labels, and helper functions (`hasScope`, `hasAllScopes`, `hasAnyScope`). Created `auth.service.ts` with `AuthService` interface and `MockAuthService` implementation providing: user info with tenant context, scope-based permission methods (`canViewGraph`, `canEditGraph`, `canExportGraph`, `canSimulate`). Integrated into `GraphExplorerComponent` via `AUTH_SERVICE` injection token: added computed signals for scope-based permissions (`canViewGraph`, `canEditGraph`, `canExportGraph`, `canSimulate`, `canCreateException`), current user info, and user scopes list. Stub implementation allows Graph Explorer development to proceed; will be replaced by generated SDK exports from SPRINT_0208_0001_0001_sdk. Files added: `src/app/core/auth/scopes.ts`, `src/app/core/auth/auth.service.ts`, `src/app/core/auth/index.ts`. Files updated: `graph-explorer.component.ts`. | UI Guild | +| 2025-11-27 | UI-AOC-19-001/002/003: Implemented Sources dashboard with AOC metrics tiles, violation drill-down, and "Verify last 24h" action. Created domain models (`aoc.models.ts`) for AocDashboardSummary, AocPassFailSummary, AocViolationCode, IngestThroughput, AocSource, AocCheckResult, VerificationRequest, ViolationDetail, OffendingField, and ProvenanceMetadata. Created mock API service (`aoc.client.ts`) with fixtures showing pass/fail metrics, 5 violation codes (AOC-001 through AOC-020), 4 tenant throughput records, 4 sources (registry, pipeline, manual), and sample check results. Built `AocDashboardComponent` (`/sources` route) with 3 tiles: (1) Pass/Fail tile with large pass rate percentage, trend indicator (improving/stable/degrading), mini 7-day chart, passed/failed/pending counts; (2) Recent Violations tile with severity badges, violation codes, names, counts, and modal detail view; (3) Ingest Throughput tile with total documents/bytes and per-tenant breakdown table. Added Sources section showing source cards with type icons, pass rates, recent violation chips, and last check time. Implemented "Verify Last 24h" button triggering verification endpoint with progress feedback and CLI parity command display (`stella aoc verify --since 24h --output json`). Created `ViolationDetailComponent` (`/sources/violations/:code` route) showing all occurrences of a violation code with: offending fields list (JSON path, expected vs actual values, reason), provenance metadata (source type/URI, build ID, commit SHA, pipeline URL), and suggested fix. Files added: `src/app/core/api/aoc.{models,client}.ts`, `src/app/features/sources/aoc-dashboard.component.{ts,html,scss}`, `violation-detail.component.ts`, `index.ts`. Routes registered at `/sources` and `/sources/violations/:code`. | UI Guild | +| 2025-11-27 | UI-POLICY-DET-01: Implemented Release flow with policy gate indicators and remediation hints for determinism blocking. Created domain models (`release.models.ts`) for Release, ReleaseArtifact, PolicyEvaluation, PolicyGateResult, RemediationHint, RemediationStep, and DeterminismFeatureFlags. Created mock API service (`release.client.ts`) with fixtures for passing/blocked/mixed releases showing determinism gate scenarios. Built `ReleaseFlowComponent` (`/releases` route) with list/detail views: list shows release cards with gate status pips and blocking indicators; detail view shows artifact tabs, policy gate evaluations, determinism evidence (Merkle root, fragment verification count, failed layers), and publish/bypass actions. Created `PolicyGateIndicatorComponent` with expandable gate details, status icons, blocking badges, and feature flag info display. Created `RemediationHintsComponent` with severity badges, estimated effort, numbered remediation steps with CLI commands (copy-to-clipboard), documentation links, automated action buttons, and exception request option. Feature-flagged via `DeterminismFeatureFlags` (blockOnFailure, warnOnly, bypassRoles). Bypass modal allows requesting exceptions with justification. Files added: `src/app/core/api/release.{models,client}.ts`, `src/app/features/releases/release-flow.component.{ts,html,scss}`, `policy-gate-indicator.component.ts`, `remediation-hints.component.ts`, `index.ts`. Routes registered at `/releases` and `/releases/:releaseId`. | UI Guild | +| 2025-11-27 | UI-ENTROPY-40-002: Implemented entropy policy banner with threshold explanations and mitigation steps. Created `EntropyPolicyBannerComponent` showing: pass/warn/block decision based on configurable thresholds (default block at 15% image opaque ratio, warn at 30% file opaque ratio), detailed reasons for decision, recommended mitigations (provide provenance, unpack binaries, include debug symbols), current vs threshold comparisons, expandable details with suppression options info, and tooltip explaining entropy concepts. Banner auto-evaluates entropy evidence and displays appropriate styling (green/yellow/red). Includes download link to `entropy.report.json` for offline audits. Integrated into scan-detail-page above entropy panel. Files updated: `scan-detail-page.component.{ts,html}`. Files added: `entropy-policy-banner.component.ts`. | UI Guild | +| 2025-11-27 | UI-ENTROPY-40-001: Implemented entropy visualization with layer donut chart, file heatmaps, and "Why risky?" chips. Extended `scanner.models.ts` with `EntropyEvidence`, `EntropyReport`, `EntropyLayerSummaryReport`, `EntropyFile`, `EntropyWindow`, and `EntropyLayerSummary` interfaces. Created `EntropyPanelComponent` with 3 views (Summary, Layers, Files): Summary shows layer donut chart with opaque ratio distribution, risk indicator chips (packed, no-symbols, stripped, UPX packer detection), entropy penalty and opaque ratio stats. Layers view shows per-layer bar charts with opaque bytes and indicators. Files view shows expandable file cards with entropy heatmaps (green-to-red gradient), file flags, and high-entropy window tables. Added mock entropy data to scan fixtures (low-risk and high-risk scenarios). Integrated panel into scan-detail-page. Files updated: `scanner.models.ts`, `scan-fixtures.ts`, `scan-detail-page.component.{ts,html,scss}`. Files added: `entropy-panel.component.ts`. | UI Guild | +| 2025-11-27 | UI-SBOM-DET-01: Implemented Determinism badge with drill-down view surfacing fragment hashes, `_composition.json`, and Merkle root consistency. Extended `scanner.models.ts` with `DeterminismEvidence`, `CompositionManifest`, and `FragmentAttestation` interfaces. Created `DeterminismBadgeComponent` with expandable details showing: Merkle root with consistency status, content hash, composition manifest URI with fragment count, fragment attestations list with DSSE verification status per layer, and Stella properties (`stellaops:stella.contentHash`, `stellaops:composition.manifest`, `stellaops:merkle.root`). Added mock determinism data to scan fixtures (verified and failed scenarios). Integrated badge into scan-detail-page. Files updated: `scanner.models.ts`, `scan-fixtures.ts`, `scan-detail-page.component.{ts,html,scss}`. Files added: `determinism-badge.component.ts`. | UI Guild | +| 2025-11-27 | UI-LNM-22-001: Implemented Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links. Created domain models (`evidence.models.ts`) for Observation, Linkset, PolicyEvidence, AocChainEntry with SOURCE_INFO metadata. Created mock API service (`evidence.client.ts`) with detailed Log4Shell (CVE-2021-44228) example data from ghsa/nvd/osv sources. Built `EvidencePanelComponent` with 4 tabs (Observations, Linkset, Policy, AOC Chain), side-by-side/stacked observation view toggle, conflict banner with expandable details, severity badges, provenance metadata display, and raw JSON download. Added `EvidencePageComponent` wrapper for direct routing with loading/error states. Files added: `src/app/core/api/evidence.{models,client}.ts`, `src/app/features/evidence/evidence-panel.component.{ts,html,scss}`, `evidence-page.component.ts`, `index.ts`. Route registered at `/evidence/:advisoryId`. | UI Guild | | 2025-11-26 | UI-EXC-25-005: Implemented keyboard shortcuts (X=create, A=approve, R=reject, Esc=close) and screen-reader messaging for Exception Center. Added `@HostListener` for global keyboard event handling with input field detection to avoid conflicts. Added ARIA live region for screen-reader announcements on all workflow transitions (approve, reject, revoke, submit for review). Added visual keyboard hints bar showing available shortcuts. All transition methods now announce their actions to screen readers before/after execution. Enhanced buttons with `aria-label` attributes including keyboard shortcut hints. Files updated: `exception-center.component.ts` (keyboard handlers, announceToScreenReader method, OnDestroy cleanup), `exception-center.component.html` (ARIA live region, keyboard hints bar, aria-labels), `exception-center.component.scss` (sr-only class, keyboard-hints styling). | UI Guild | | 2025-11-26 | UI-EXC-25-004: Implemented exception badges with countdown timers and explain integration across Vulnerability Explorer and Graph Explorer. Created reusable `ExceptionBadgeComponent` with expandable view, live countdown timer (updates every minute), severity/status indicators, accessibility support (ARIA labels, keyboard navigation), and expiring-soon visual warnings. Created `ExceptionExplainComponent` modal with scope explanation, impact stats, timeline, approval info, and severity-based warnings. Integrated components into both explorers with badge data mapping and explain modal overlays. Files added: `shared/components/exception-badge.component.ts`, `shared/components/exception-explain.component.ts`, `shared/components/index.ts`. Updated `vulnerability-explorer.component.{ts,html,scss}` and `graph-explorer.component.{ts,html,scss}` with badge/explain integration. | UI Guild | | 2025-11-26 | UI-EXC-25-003: Implemented inline exception drafting from Vulnerability Explorer and Graph Explorer. Created reusable `ExceptionDraftInlineComponent` with context-aware pre-population (vulnIds, componentPurls, assetIds), quick justification templates, timebox presets, and live impact simulation showing affected findings count/policy impact/coverage estimate. Created new Vulnerability Explorer (`/vulnerabilities` route) with 10 mock CVEs, severity/status filters, detail panel with affected components, and inline exception drafting. Created Graph Explorer (`/graph` route) with hierarchy/flat views, layer toggles (assets/components/vulnerabilities), severity filters, and context-aware inline exception drafting from any selected node. Files added: `exception-draft-inline.component.{ts,html,scss}`, `vulnerability.{models,client}.ts`, `vulnerability-explorer.component.{ts,html,scss}`, `graph-explorer.component.{ts,html,scss}`. Routes registered at `/vulnerabilities` and `/graph`. | UI Guild | diff --git a/docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md b/docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md index b1cd8f474..f4bb78187 100644 --- a/docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md +++ b/docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md @@ -44,7 +44,7 @@ | 9 | RUNTIME-PROBE-401-010 | TODO | Depends on probe collectors; align with ingestion endpoint. | Runtime Signals Guild (`src/Signals/StellaOps.Signals.Runtime`, `ops/probes`) | Implement lightweight runtime probes (EventPipe/JFR) emitting CAS traces feeding Signals ingestion. | | 10 | SIGNALS-SCORING-401-003 | TODO | Needs runtime hit feeds from 8/9; confirm scoring weights. | Signals Guild (`src/Signals/StellaOps.Signals`) | Extend ReachabilityScoringService with deterministic scoring, persist labels, expose `/graphs/{scanId}` CAS lookups. | | 11 | REPLAY-401-004 | BLOCKED | Requires CAS registration policy from GAP-REP-004. | BE-Base Platform Guild (`src/__Libraries/StellaOps.Replay.Core`) | Bump replay manifest to v2, enforce CAS registration + hash sorting in ReachabilityReplayWriter, add deterministic tests. | -| 12 | AUTH-REACH-401-005 | TODO | Blocked on DSSE predicate definitions; align with Signer. | Authority & Signer Guilds (`src/Authority/StellaOps.Authority`, `src/Signer/StellaOps.Signer`) | Introduce DSSE predicate types for SBOM/Graph/VEX/Replay, plumb signing, mirror statements to Rekor (incl. PQ variants). | +| 12 | AUTH-REACH-401-005 | DONE (2025-11-27) | Predicate types exist; DSSE signer service added. | Authority & Signer Guilds (`src/Authority/StellaOps.Authority`, `src/Signer/StellaOps.Signer`) | Introduce DSSE predicate types for SBOM/Graph/VEX/Replay, plumb signing, mirror statements to Rekor (incl. PQ variants). | | 13 | POLICY-VEX-401-006 | TODO | Needs reachability facts from Signals and thresholds confirmation. | Policy Guild (`src/Policy/StellaOps.Policy.Engine`, `src/Policy/__Libraries/StellaOps.Policy`) | Consume reachability facts, bucket scores, emit OpenVEX with call-path proofs, update SPL schema with reachability predicates and suppression gates. | | 14 | POLICY-VEX-401-010 | TODO | Depends on 13 and DSSE path; follow bench playbook. | Policy Guild (`src/Policy/StellaOps.Policy.Engine/Vex`, `docs/modules/policy/architecture.md`, `docs/benchmarks/vex-evidence-playbook.md`) | Implement VexDecisionEmitter to serialize per-finding OpenVEX, attach evidence hashes, request DSSE signatures, capture Rekor metadata. | | 15 | UI-CLI-401-007 | TODO | Requires graph CAS outputs + policy evidence; sync CLI/UI. | UI & CLI Guilds (`src/Cli/StellaOps.Cli`, `src/UI/StellaOps.UI`) | Implement CLI `stella graph explain` and UI explain drawer with signed call-path, predicates, runtime hits, DSSE pointers, counterfactual controls. | @@ -66,8 +66,8 @@ | 31 | POLICY-ENGINE-401-003 | TODO | Depends on 29/30; ensure determinism hashes stable. | Policy Guild (`src/Policy/StellaOps.Policy.Engine`, `docs/modules/policy/architecture.md`) | Replace in-service DSL compilation with shared library, support legacy packs and inline syntax, keep determinism stable. | | 32 | CLI-EDITOR-401-004 | TODO | Relies on shared DSL lib; add git edit flow. | CLI Guild (`src/Cli/StellaOps.Cli`, `docs/policy/lifecycle.md`) | Enhance `stella policy` verbs (edit/lint/simulate) to edit Git-backed DSL files, run coverage tests, commit SemVer metadata. | | 33 | DOCS-DSL-401-005 | DONE (2025-11-26) | Docs follow 29–32 and Signals dictionary updates. | Docs Guild (`docs/policy/dsl.md`, `docs/policy/lifecycle.md`) | Refresh DSL docs with new syntax, signal dictionary (`trust_score`, `reachability`, etc.), authoring workflow, safety rails. | -| 34 | DSSE-LIB-401-020 | TODO | Align with DSSE predicate work; reusable lib. | Attestor Guild · Platform Guild (`src/Attestor/StellaOps.Attestation`, `src/Attestor/StellaOps.Attestor.Envelope`) | Package `StellaOps.Attestor.Envelope` primitives into reusable `StellaOps.Attestation` library with InToto/DSSE helpers. | -| 35 | DSSE-CLI-401-021 | TODO | Depends on 34; deliver CLI/workflow snippets. | CLI Guild · DevOps Guild (`src/Cli/StellaOps.Cli`, `scripts/ci/attest-*`, `docs/modules/attestor/architecture.md`) | Ship `stella attest` CLI or sample tool plus GitLab/GitHub workflow snippets emitting DSSE per build step. | +| 34 | DSSE-LIB-401-020 | DONE (2025-11-27) | Transitive dependency exposes Envelope types; extensions added. | Attestor Guild · Platform Guild (`src/Attestor/StellaOps.Attestation`, `src/Attestor/StellaOps.Attestor.Envelope`) | Package `StellaOps.Attestor.Envelope` primitives into reusable `StellaOps.Attestation` library with InToto/DSSE helpers. | +| 35 | DSSE-CLI-401-021 | DONE (2025-11-27) | Depends on 34; deliver CLI/workflow snippets. | CLI Guild · DevOps Guild (`src/Cli/StellaOps.Cli`, `scripts/ci/attest-*`, `docs/modules/attestor/architecture.md`) | Ship `stella attest` CLI or sample tool plus GitLab/GitHub workflow snippets emitting DSSE per build step. | | 36 | DSSE-DOCS-401-022 | TODO | Follows 34/35; document build-time flow. | Docs Guild · Attestor Guild (`docs/ci/dsse-build-flow.md`, `docs/modules/attestor/architecture.md`) | Document build-time attestation walkthrough: models, helper usage, Authority integration, storage conventions, verification commands. | | 37 | REACH-LATTICE-401-023 | TODO | Align Scanner + Policy schemas; tie to evidence joins. | Scanner Guild · Policy Guild (`docs/reachability/lattice.md`, `docs/modules/scanner/architecture.md`, `src/Scanner/StellaOps.Scanner.WebService`) | Define reachability lattice model and ensure joins write to event graph schema. | | 38 | UNCERTAINTY-SCHEMA-401-024 | TODO | Schema changes rely on Signals ingestion work. | Signals Guild (`src/Signals/StellaOps.Signals`, `docs/uncertainty/README.md`) | Extend Signals findings with uncertainty states, entropy fields, `riskScore`; emit update events and persist evidence. | @@ -136,6 +136,9 @@ ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-27 | Completed AUTH-REACH-401-005: added `StellaOps.Attestation` reference to Authority project; created `AuthoritySignerAdapter` to wrap ICryptoSigner as IAuthoritySigner; created `IAuthorityDsseStatementSigner` interface and `AuthorityDsseStatementSigner` service for signing In-toto statements with Authority's signing keys; service reuses existing DsseHelper.WrapAsync for DSSE envelope creation; fixed null-reference issue in DsseHelper.cs. Rekor mirroring leverages existing Attestor `IRekorClient` infrastructure. | Authority Guild | +| 2025-11-27 | Completed DSSE-LIB-401-020: `StellaOps.Attestation` library now packages Envelope primitives. Added `DsseEnvelopeExtensions.cs` with conversion utilities (`ToSerializableDict`, `FromBase64`, `GetPayloadString`, `GetPayloadBase64`). Envelope types (`DsseEnvelope`, `DsseSignature`, etc.) are exposed as transitive dependencies; consumers only need to reference `StellaOps.Attestation` to access both high-level InToto/DSSE helpers and low-level envelope primitives. Build verified. | Attestor Guild | +| 2025-11-27 | Completed DSSE-CLI-401-021: implemented `stella attest` CLI command with verify/list/show subcommands in `CommandFactory.cs` and `CommandHandlers.cs`. Added handlers for offline DSSE verification (`HandleAttestVerifyAsync`), attestation listing (`HandleAttestListAsync`), and attestation details (`HandleAttestShowAsync`). Added CI workflow snippets for GitHub Actions and GitLab CI to `docs/modules/cli/guides/attest.md`. Fixed pre-existing build errors (`SanitizeFileName` missing, `NodePackageCollector.AttachEntrypoints` parameter mismatch). All CLI commands functional with placeholder handlers for backend integration. | CLI Guild | | 2025-11-26 | Completed SIGN-VEX-401-018: added `stella.ops/vexDecision@v1` and `stella.ops/graph@v1` predicate types to PredicateTypes.cs; added helper methods IsVexRelatedType, IsReachabilityRelatedType, GetAllowedPredicateTypes, IsAllowedPredicateType; added OpenVEX VexDecisionPredicateJson and richgraph-v1 GraphPredicateJson fixtures; updated SigningRequestBuilder with WithVexDecisionPredicate and WithGraphPredicate; added 12 new unit tests covering new predicate types and helper methods; updated integration tests to cover all 8 StellaOps predicate types. All 102 Signer tests pass. | Signing Guild | | 2025-11-26 | BENCH-DETERMINISM-401-057 completed: added offline harness + mock scanner at `src/Bench/StellaOps.Bench/Determinism`, sample SBOM/VEX inputs, manifests (`results/inputs.sha256`), and summary output; unit tests under `Determinism/tests` passing. | Bench Guild | | 2025-11-26 | BENCH-DETERMINISM-401-057 follow-up: default runs set to 10 per scanner/SBOM pair; harness supports `--manifest-extra`/`DET_EXTRA_INPUTS` for frozen feeds; CI wrapper enforces threshold. | Bench Guild | diff --git a/docs/implplan/SPRINT_0512_0001_0001_bench.md b/docs/implplan/SPRINT_0512_0001_0001_bench.md index 8615ec8f1..ba96db893 100644 --- a/docs/implplan/SPRINT_0512_0001_0001_bench.md +++ b/docs/implplan/SPRINT_0512_0001_0001_bench.md @@ -76,6 +76,7 @@ ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-11-26 | Added optional reachability hashing path (DET_REACH_GRAPHS/DET_REACH_RUNTIME) to determinism run script; reachability helper `run_reachability.py` with sample graph/runtime fixtures and unit tests added. | Bench Guild | | 2025-11-26 | Default runs raised to 10 per scanner/SBOM pair in harness and determinism-run wrapper to match 10x2 matrix requirement. | Bench Guild | | 2025-11-26 | Added DET_EXTRA_INPUTS/DET_RUN_EXTRA_ARGS support to determinism run script to include frozen feeds in manifests; documented in scripts/bench/README.md. | Bench Guild | | 2025-11-26 | Added scripts/bench/README.md documenting determinism-run wrapper and threshold env. | Bench Guild | diff --git a/docs/implplan/SPRINT_0514_0001_0001_sovereign_crypto_enablement.md b/docs/implplan/SPRINT_0514_0001_0001_sovereign_crypto_enablement.md index e06f01757..5bcf1b8eb 100644 --- a/docs/implplan/SPRINT_0514_0001_0001_sovereign_crypto_enablement.md +++ b/docs/implplan/SPRINT_0514_0001_0001_sovereign_crypto_enablement.md @@ -31,10 +31,10 @@ | 8 | SEC-CRYPTO-90-014 | BLOCKED | Authority provider/JWKS contract pending (R1) | Security Guild + Service Guilds | Update runtime hosts (Authority, Scanner WebService/Worker, Concelier, etc.) to register RU providers and expose config toggles. | | 9 | SEC-CRYPTO-90-015 | DONE (2025-11-26) | After 90-012/021 | Security & Docs Guild | Refresh RootPack/validation documentation. | | 10 | AUTH-CRYPTO-90-001 | BLOCKED | PREP-AUTH-CRYPTO-90-001-NEEDS-AUTHORITY-PROVI | Authority Core & Security Guild | Sovereign signing provider contract for Authority; refactor loaders once contract is published. | -| 11 | SCANNER-CRYPTO-90-001 | TODO | Needs registry wiring | Scanner WebService Guild · Security Guild | Route hashing/signing flows through `ICryptoProviderRegistry`. | -| 12 | SCANNER-WORKER-CRYPTO-90-001 | TODO | After 11 | Scanner Worker Guild · Security Guild | Wire Scanner Worker/BuildX analyzers to registry/hash abstractions. | -| 13 | SCANNER-CRYPTO-90-002 | TODO | PQ profile | Scanner WebService Guild · Security Guild | Enable PQ-friendly DSSE (Dilithium/Falcon) via provider options. | -| 14 | SCANNER-CRYPTO-90-003 | TODO | After 13 | Scanner Worker Guild · QA Guild | Add regression tests for RU/PQ profiles validating Merkle roots + DSSE chains. | +| 11 | SCANNER-CRYPTO-90-001 | BLOCKED (2025-11-27) | Await Authority provider/JWKS contract + registry option design (R1/R3) | Scanner WebService Guild · Security Guild | Route hashing/signing flows through `ICryptoProviderRegistry`. | +| 12 | SCANNER-WORKER-CRYPTO-90-001 | BLOCKED (2025-11-27) | After 11 (registry contract pending) | Scanner Worker Guild · Security Guild | Wire Scanner Worker/BuildX analyzers to registry/hash abstractions. | +| 13 | SCANNER-CRYPTO-90-002 | BLOCKED (2025-11-27) | PQ provider option design pending (R3) | Scanner WebService Guild · Security Guild | Enable PQ-friendly DSSE (Dilithium/Falcon) via provider options. | +| 14 | SCANNER-CRYPTO-90-003 | BLOCKED (2025-11-27) | After 13; needs PQ provider options | Scanner Worker Guild · QA Guild | Add regression tests for RU/PQ profiles validating Merkle roots + DSSE chains. | | 15 | ATTESTOR-CRYPTO-90-001 | BLOCKED | Authority provider/JWKS contract pending (R1) | Attestor Service Guild · Security Guild | Migrate attestation hashing/witness flows to provider registry, enabling CryptoPro/PKCS#11 deployments. | ## Wave Coordination @@ -83,6 +83,7 @@ | --- | --- | --- | | 2025-11-26 | Completed SEC-CRYPTO-90-018: added fork sync steps/licensing guidance and RootPack packaging notes; marked task DONE. | Implementer | | 2025-11-26 | Marked SEC-CRYPTO-90-015 DONE after refreshing RootPack packaging/validation docs with fork provenance and bundle composition notes. | Implementer | +| 2025-11-27 | Marked SCANNER-CRYPTO-90-001/002/003 and SCANNER-WORKER-CRYPTO-90-001 BLOCKED pending Authority provider/JWKS contract and PQ provider option design (R1/R3). | Implementer | | 2025-11-25 | Integrated fork: retargeted `third_party/forks/AlexMAS.GostCryptography` to `net10.0`, added Xml/Permissions deps, and switched `StellaOps.Cryptography.Plugin.CryptoPro` from IT.GostCryptography nuget to project reference. `dotnet build src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro -c Release` now succeeds (warnings CA1416 kept). | Implementer | | 2025-11-25 | Progressed SEC-CRYPTO-90-019: removed legacy IT.GostCryptography nuget, retargeted fork to net10 with System.Security.Cryptography.Xml 8.0.1 and System.Security.Permissions; cleaned stale bin/obj. Fork library builds; fork tests still pending (Windows CSP). | Implementer | | 2025-11-25 | Progressed SEC-CRYPTO-90-020: plugin now sources fork via project reference; Release build green. Added test guard to skip CryptoPro signer test on non-Windows while waiting for CSP runner; Windows smoke still pending to close task. | Implementer | diff --git a/docs/implplan/SPRINT_186_record_deterministic_execution.md b/docs/implplan/SPRINT_186_record_deterministic_execution.md index 60e19aa59..12a59a991 100644 --- a/docs/implplan/SPRINT_186_record_deterministic_execution.md +++ b/docs/implplan/SPRINT_186_record_deterministic_execution.md @@ -15,7 +15,7 @@ SIGN-TEST-186-006 | TODO | Upgrade signer integration tests to run against the r AUTH-VERIFY-186-007 | TODO | Expose an Authority-side verification helper/service that validates DSSE signatures and Rekor proofs for promotion attestations using trusted checkpoints, enabling offline audit flows. | Authority Guild, Provenance Guild (`src/Authority/StellaOps.Authority`, `src/Provenance/StellaOps.Provenance.Attestation`) SCAN-DETER-186-008 | DONE (2025-11-26) | Add deterministic execution switches to Scanner (fixed clock, RNG seed, concurrency cap, feed/policy snapshot pins, log filtering) available via CLI/env/config so repeated runs stay hermetic. | Scanner Guild (`src/Scanner/StellaOps.Scanner.WebService`, `src/Scanner/StellaOps.Scanner.Worker`) SCAN-DETER-186-009 | TODO | Build a determinism harness that replays N scans per image, canonicalises SBOM/VEX/findings/log outputs, and records per-run hash matrices (see `docs/modules/scanner/determinism-score.md`). | Scanner Guild, QA Guild (`src/Scanner/StellaOps.Scanner.Replay`, `src/Scanner/__Tests`) -SCAN-DETER-186-010 | TODO | Emit and publish `determinism.json` (scores, artifact hashes, non-identical diffs) alongside each scanner release via CAS/object storage APIs (documented in `docs/modules/scanner/determinism-score.md`). | Scanner Guild, Export Center Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/operations/release.md`) +SCAN-DETER-186-010 | DONE (2025-11-27) | Emit and publish `determinism.json` (scores, artifact hashes, non-identical diffs) alongside each scanner release via CAS/object storage APIs (documented in `docs/modules/scanner/determinism-score.md`). | Scanner Guild, Export Center Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/operations/release.md`) SCAN-ENTROPY-186-011 | DONE (2025-11-26) | Implement entropy analysis for ELF/PE/Mach-O executables and large opaque blobs (sliding-window metrics, section heuristics), flagging high-entropy regions and recording offsets/hints (see `docs/modules/scanner/entropy.md`). | Scanner Guild (`src/Scanner/StellaOps.Scanner.Worker`, `src/Scanner/__Libraries`) SCAN-ENTROPY-186-012 | DONE (2025-11-26) | Generate `entropy.report.json` and image-level penalties, attach evidence to scan manifests/attestations, and expose opaque ratios for downstream policy engines (`docs/modules/scanner/entropy.md`). | Scanner Guild, Provenance Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/replay/DETERMINISTIC_REPLAY.md`) SCAN-CACHE-186-013 | TODO | Implement layer-level SBOM/VEX cache keyed by (layer digest + manifest hash + tool/feed/policy IDs); re-verify DSSE attestations on cache hits and persist indexes for reuse/diagnostics; document in `docs/modules/scanner/architecture.md` referencing the 16-Nov-2026 layer cache advisory. | Scanner Guild (`src/Scanner/StellaOps.Scanner.WebService`, `src/Scanner/StellaOps.Scanner.Worker`, `docs/modules/scanner/architecture.md`) @@ -32,4 +32,6 @@ DOCS-REPLAY-186-004 | DONE (2025-11-26) | Author `docs/replay/TEST_STRATEGY.md` | 2025-11-26 | Added `docs/modules/scanner/deterministic-execution.md` with deterministic switches, ordering rules, hashing, and offline guidance; supports SCAN-REPLAY-186-002 planning. | Docs Guild | | 2025-11-26 | SCAN-REPLAY-186-001 completed: RecordModeService now assembles replay manifests, writes input/output CAS bundles with policy/feed/tool pins, reachability refs, attaches to scan snapshots; architecture doc updated. | Scanner Guild | | 2025-11-26 | SCAN-ENTROPY-186-011/012 completed: entropy stage emits windowed metrics; WebService surfaces entropy reports/layer summaries via surface manifest, status API; docs already published. | Scanner Guild | +| 2025-11-27 | Surface manifest now emits `determinism.json` (pins + runtime toggles) to support replay verification; worker determinism context carries concurrency cap. | Scanner Guild | +| 2025-11-27 | SCAN-DETER-186-010 completed: determinism.json now published with per-payload hashes in surface manifest, satisfying determinism evidence requirements for release bundles. | Scanner Guild | | 2025-11-26 | SCAN-DETER-186-008 implemented: determinism pins for feed/policy metadata, policy pin enforcement, concurrency clamp, validation/tests. | Scanner Guild | diff --git a/docs/implplan/tasks-all.md b/docs/implplan/tasks-all.md index 98a0b38dd..1a0cc5df2 100644 --- a/docs/implplan/tasks-all.md +++ b/docs/implplan/tasks-all.md @@ -264,7 +264,7 @@ | AUTH-DPOP-11-001 | DONE (2025-11-08) | 2025-11-08 | SPRINT_100_identity_signing | Authority Core & Security Guild (src/Authority/StellaOps.Authority) | src/Authority/StellaOps.Authority | DPoP validation now runs for every `/token` grant, interactive tokens inherit `cnf.jkt`/sender claims, and docs/tests document the expanded coverage. | AUTH-AOC-19-002 | AUIN0101 | | AUTH-MTLS-11-002 | DONE (2025-11-08) | 2025-11-08 | SPRINT_100_identity_signing | Authority Core & Security Guild (src/Authority/StellaOps.Authority) | src/Authority/StellaOps.Authority | Refresh grants now enforce the original client certificate, tokens persist `x5t#S256`/hex metadata via shared helper, and docs/JWKS guidance call out the mTLS binding expectations. | AUTH-DPOP-11-001 | AUIN0101 | | AUTH-PACKS-43-001 | DONE (2025-11-09) | 2025-11-09 | SPRINT_100_identity_signing | Authority Core & Security Guild (src/Authority/StellaOps.Authority) | src/Authority/StellaOps.Authority | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | AUTH-PACKS-41-001; TASKRUN-42-001; ORCH-SVC-42-101 | AUIN0101 | -| AUTH-REACH-401-005 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Authority & Signer Guilds | `src/Authority/StellaOps.Authority`, `src/Signer/StellaOps.Signer` | Introduce DSSE predicate types for SBOM/Graph/VEX/Replay, plumb signing through Authority + Signer, and mirror statements to Rekor (including PQ variants where required). | Coordinate with replay reachability owners | AUIN0101 | +| AUTH-REACH-401-005 | DONE (2025-11-27) | 2025-11-27 | SPRINT_0401_0001_0001_reachability_evidence_chain | Authority & Signer Guilds | `src/Authority/StellaOps.Authority`, `src/Signer/StellaOps.Signer` | Predicate types exist (stella.ops/vexDecision@v1 etc.); IAuthorityDsseStatementSigner created with ICryptoProviderRegistry; Rekor via existing IRekorClient. | Coordinate with replay reachability owners | AUIN0101 | | AUTH-VERIFY-186-007 | TODO | | SPRINT_186_record_deterministic_execution | Authority Guild · Provenance Guild | `src/Authority/StellaOps.Authority`, `src/Provenance/StellaOps.Provenance.Attestation` | Expose an Authority-side verification helper/service that validates DSSE signatures and Rekor proofs for promotion attestations using trusted checkpoints, enabling offline audit flows. | Await PROB0101 provenance harness | AUIN0101 | | AUTHORITY-DOCS-0001 | TODO | | SPRINT_314_docs_modules_authority | Docs Guild (docs/modules/authority) | docs/modules/authority | See ./AGENTS.md | Wait for AUIN0101 sign-off | DOAU0101 | | AUTHORITY-ENG-0001 | TODO | | SPRINT_314_docs_modules_authority | Module Team (docs/modules/authority) | docs/modules/authority | Update status via ./AGENTS.md workflow | Depends on #1 | DOAU0101 | @@ -822,9 +822,9 @@ | DOWNLOADS-CONSOLE-23-001 | TODO | | SPRINT_502_ops_deployment_ii | Docs Guild · Deployment Guild | docs/console | Maintain signed downloads manifest pipeline (images, Helm, offline bundles), publish JSON under `deploy/downloads/manifest.json`, and document sync cadence for Console + docs parity. | Need latest console build instructions | DOCN0101 | | DPOP-11-001 | TODO | 2025-11-08 | SPRINT_100_identity_signing | Docs Guild · Authority Core | src/Authority/StellaOps.Authority | Need DPoP ADR from PGMI0101 | AUTH-AOC-19-002 | DODP0101 | | DSL-401-005 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · Policy Guild | `docs/policy/dsl.md`, `docs/policy/lifecycle.md` | Depends on PLLG0101 DSL updates | Depends on PLLG0101 DSL updates | DODP0101 | -| DSSE-CLI-401-021 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · CLI Guild | `src/Cli/StellaOps.Cli`, `scripts/ci/attest-*`, `docs/modules/attestor/architecture.md` | Ship a `stella attest` CLI (or sample `StellaOps.Attestor.Tool`) plus GitLab/GitHub workflow snippets that emit DSSE per build step (scan/package/push) using the new library and Authority keys. | Need CLI updates from latest DSSE release | DODS0101 | +| DSSE-CLI-401-021 | DONE | 2025-11-27 | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · CLI Guild | `src/Cli/StellaOps.Cli`, `scripts/ci/attest-*`, `docs/modules/attestor/architecture.md` | Ship a `stella attest` CLI (or sample `StellaOps.Attestor.Tool`) plus GitLab/GitHub workflow snippets that emit DSSE per build step (scan/package/push) using the new library and Authority keys. | Need CLI updates from latest DSSE release | DODS0101 | | DSSE-DOCS-401-022 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · Attestor Guild | `docs/ci/dsse-build-flow.md`, `docs/modules/attestor/architecture.md` | Document the build-time attestation walkthrough (`docs/ci/dsse-build-flow.md`): models, helper usage, Authority integration, storage conventions, and verification commands, aligning with the advisory. | Depends on #1 | DODS0101 | -| DSSE-LIB-401-020 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Attestor Guild · Platform Guild | `src/Attestor/StellaOps.Attestation`, `src/Attestor/StellaOps.Attestor.Envelope` | Package `StellaOps.Attestor.Envelope` primitives into a reusable `StellaOps.Attestation` library with `InTotoStatement`, `IAuthoritySigner`, DSSE pre-auth helpers, and .NET-friendly APIs for build agents. | Need attestor library API freeze | DOAL0101 | +| DSSE-LIB-401-020 | DONE (2025-11-27) | 2025-11-27 | SPRINT_0401_0001_0001_reachability_evidence_chain | Attestor Guild · Platform Guild | `src/Attestor/StellaOps.Attestation`, `src/Attestor/StellaOps.Attestor.Envelope` | DsseEnvelopeExtensions added with conversion utilities; Envelope types exposed as transitive dependencies; consumers reference only StellaOps.Attestation. | Need attestor library API freeze | DOAL0101 | | DVOFF-64-002 | TODO | | SPRINT_160_export_evidence | DevPortal Offline Guild | docs/modules/export-center/devportal-offline.md | DevPortal Offline + AirGap Controller Guilds | Needs exporter DSSE schema from 002_ATEL0101 | DEVL0102 | | EDITOR-401-004 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · CLI Guild | `src/Cli/StellaOps.Cli`, `docs/policy/lifecycle.md` | Gather CLI/editor alignment notes | Gather CLI/editor alignment notes | DOCL0103 | | EMIT-15-001 | TODO | | SPRINT_136_scanner_surface | Docs Guild · Scanner Emit Guild | src/Scanner/__Libraries/StellaOps.Scanner.Emit | Need EntryTrace emit notes from SCANNER-SURFACE-04 | SCANNER-SURFACE-04 | DOEM0101 | @@ -1586,7 +1586,7 @@ | SCAN-90-004 | TODO | | SPRINT_505_ops_devops_iii | DevOps Guild, Scanner Guild (ops/devops) | ops/devops | | | | | SCAN-DETER-186-008 | DONE (2025-11-26) | | SPRINT_186_record_deterministic_execution | Scanner Guild · Provenance Guild | `src/Scanner/StellaOps.Scanner.WebService`, `src/Scanner/StellaOps.Scanner.Worker` | Add deterministic execution switches to Scanner (fixed clock, RNG seed, concurrency cap, feed/policy snapshot pins, log filtering) available via CLI/env/config so repeated runs stay hermetic. | ENTROPY-186-012 & SCANNER-ENV-02 | SCDE0102 | | SCAN-DETER-186-009 | TODO | | SPRINT_186_record_deterministic_execution | Scanner Guild, QA Guild (`src/Scanner/StellaOps.Scanner.Replay`, `src/Scanner/__Tests`) | `src/Scanner/StellaOps.Scanner.Replay`, `src/Scanner/__Tests` | Build a determinism harness that replays N scans per image, canonicalises SBOM/VEX/findings/log outputs, and records per-run hash matrices (see `docs/modules/scanner/determinism-score.md`). | | | -| SCAN-DETER-186-010 | TODO | | SPRINT_186_record_deterministic_execution | Scanner Guild, Export Center Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/operations/release.md`) | `src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/operations/release.md` | Emit and publish `determinism.json` (scores, artifact hashes, non-identical diffs) alongside each scanner release via CAS/object storage APIs (documented in `docs/modules/scanner/determinism-score.md`). | | | +| SCAN-DETER-186-010 | DONE (2025-11-27) | | SPRINT_186_record_deterministic_execution | Scanner Guild, Export Center Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/operations/release.md`) | `src/Scanner/StellaOps.Scanner.WebService`, `docs/modules/scanner/operations/release.md` | Emit and publish `determinism.json` (scores, artifact hashes, non-identical diffs) alongside each scanner release via CAS/object storage APIs (documented in `docs/modules/scanner/determinism-score.md`). | | | | SCAN-ENTROPY-186-011 | DONE (2025-11-26) | | SPRINT_186_record_deterministic_execution | Scanner Guild (`src/Scanner/StellaOps.Scanner.Worker`, `src/Scanner/__Libraries`) | `src/Scanner/StellaOps.Scanner.Worker`, `src/Scanner/__Libraries` | Implement entropy analysis for ELF/PE/Mach-O executables and large opaque blobs (sliding-window metrics, section heuristics), flagging high-entropy regions and recording offsets/hints (see `docs/modules/scanner/entropy.md`). | | | | SCAN-ENTROPY-186-012 | DONE (2025-11-26) | | SPRINT_186_record_deterministic_execution | Scanner Guild, Provenance Guild (`src/Scanner/StellaOps.Scanner.WebService`, `docs/replay/DETERMINISTIC_REPLAY.md`) | `src/Scanner/StellaOps.Scanner.WebService`, `docs/replay/DETERMINISTIC_REPLAY.md` | Generate `entropy.report.json` and image-level penalties, attach evidence to scan manifests/attestations, and expose opaque ratios for downstream policy engines (`docs/modules/scanner/entropy.md`). | | | | SCAN-REACH-201-002 | DOING | 2025-11-08 | SPRINT_400_runtime_facts_static_callgraph_union | Scanner Worker Guild (`src/Scanner/StellaOps.Scanner.Worker`) | `src/Scanner/StellaOps.Scanner.Worker` | Ship language-aware static lifters (JVM, .NET/Roslyn+IL, Go SSA, Node/Deno TS AST, Rust MIR, Swift SIL, shell/binary analyzers) in Scanner Worker; emit canonical SymbolIDs, CAS-stored graphs, and attach reachability tags to SBOM components. | | | @@ -2475,7 +2475,7 @@ | AUTH-DPOP-11-001 | DONE (2025-11-08) | 2025-11-08 | SPRINT_100_identity_signing | Authority Core & Security Guild (src/Authority/StellaOps.Authority) | src/Authority/StellaOps.Authority | DPoP validation now runs for every `/token` grant, interactive tokens inherit `cnf.jkt`/sender claims, and docs/tests document the expanded coverage. | AUTH-AOC-19-002 | AUIN0101 | | AUTH-MTLS-11-002 | DONE (2025-11-08) | 2025-11-08 | SPRINT_100_identity_signing | Authority Core & Security Guild (src/Authority/StellaOps.Authority) | src/Authority/StellaOps.Authority | Refresh grants now enforce the original client certificate, tokens persist `x5t#S256`/hex metadata via shared helper, and docs/JWKS guidance call out the mTLS binding expectations. | AUTH-DPOP-11-001 | AUIN0101 | | AUTH-PACKS-43-001 | DONE (2025-11-09) | 2025-11-09 | SPRINT_100_identity_signing | Authority Core & Security Guild (src/Authority/StellaOps.Authority) | src/Authority/StellaOps.Authority | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | AUTH-PACKS-41-001; TASKRUN-42-001; ORCH-SVC-42-101 | AUIN0101 | -| AUTH-REACH-401-005 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Authority & Signer Guilds | `src/Authority/StellaOps.Authority`, `src/Signer/StellaOps.Signer` | Introduce DSSE predicate types for SBOM/Graph/VEX/Replay, plumb signing through Authority + Signer, and mirror statements to Rekor (including PQ variants where required). | Coordinate with replay reachability owners | AUIN0101 | +| AUTH-REACH-401-005 | DONE (2025-11-27) | 2025-11-27 | SPRINT_0401_0001_0001_reachability_evidence_chain | Authority & Signer Guilds | `src/Authority/StellaOps.Authority`, `src/Signer/StellaOps.Signer` | Predicate types exist (stella.ops/vexDecision@v1 etc.); IAuthorityDsseStatementSigner created with ICryptoProviderRegistry; Rekor via existing IRekorClient. | Coordinate with replay reachability owners | AUIN0101 | | AUTH-VERIFY-186-007 | TODO | | SPRINT_186_record_deterministic_execution | Authority Guild · Provenance Guild | `src/Authority/StellaOps.Authority`, `src/Provenance/StellaOps.Provenance.Attestation` | Expose an Authority-side verification helper/service that validates DSSE signatures and Rekor proofs for promotion attestations using trusted checkpoints, enabling offline audit flows. | Await PROB0101 provenance harness | AUIN0101 | | AUTHORITY-DOCS-0001 | TODO | | SPRINT_314_docs_modules_authority | Docs Guild (docs/modules/authority) | docs/modules/authority | See ./AGENTS.md | Wait for AUIN0101 sign-off | DOAU0101 | | AUTHORITY-ENG-0001 | TODO | | SPRINT_314_docs_modules_authority | Module Team (docs/modules/authority) | docs/modules/authority | Update status via ./AGENTS.md workflow | Depends on #1 | DOAU0101 | @@ -3035,9 +3035,9 @@ | DOWNLOADS-CONSOLE-23-001 | TODO | | SPRINT_502_ops_deployment_ii | Docs Guild · Deployment Guild | docs/console | Maintain signed downloads manifest pipeline (images, Helm, offline bundles), publish JSON under `deploy/downloads/manifest.json`, and document sync cadence for Console + docs parity. | Need latest console build instructions | DOCN0101 | | DPOP-11-001 | TODO | 2025-11-08 | SPRINT_100_identity_signing | Docs Guild · Authority Core | src/Authority/StellaOps.Authority | Need DPoP ADR from PGMI0101 | AUTH-AOC-19-002 | DODP0101 | | DSL-401-005 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · Policy Guild | `docs/policy/dsl.md`, `docs/policy/lifecycle.md` | Depends on PLLG0101 DSL updates | Depends on PLLG0101 DSL updates | DODP0101 | -| DSSE-CLI-401-021 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · CLI Guild | `src/Cli/StellaOps.Cli`, `scripts/ci/attest-*`, `docs/modules/attestor/architecture.md` | Ship a `stella attest` CLI (or sample `StellaOps.Attestor.Tool`) plus GitLab/GitHub workflow snippets that emit DSSE per build step (scan/package/push) using the new library and Authority keys. | Need CLI updates from latest DSSE release | DODS0101 | +| DSSE-CLI-401-021 | DONE | 2025-11-27 | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · CLI Guild | `src/Cli/StellaOps.Cli`, `scripts/ci/attest-*`, `docs/modules/attestor/architecture.md` | Ship a `stella attest` CLI (or sample `StellaOps.Attestor.Tool`) plus GitLab/GitHub workflow snippets that emit DSSE per build step (scan/package/push) using the new library and Authority keys. | Need CLI updates from latest DSSE release | DODS0101 | | DSSE-DOCS-401-022 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · Attestor Guild | `docs/ci/dsse-build-flow.md`, `docs/modules/attestor/architecture.md` | Document the build-time attestation walkthrough (`docs/ci/dsse-build-flow.md`): models, helper usage, Authority integration, storage conventions, and verification commands, aligning with the advisory. | Depends on #1 | DODS0101 | -| DSSE-LIB-401-020 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Attestor Guild · Platform Guild | `src/Attestor/StellaOps.Attestation`, `src/Attestor/StellaOps.Attestor.Envelope` | Package `StellaOps.Attestor.Envelope` primitives into a reusable `StellaOps.Attestation` library with `InTotoStatement`, `IAuthoritySigner`, DSSE pre-auth helpers, and .NET-friendly APIs for build agents. | Need attestor library API freeze | DOAL0101 | +| DSSE-LIB-401-020 | DONE (2025-11-27) | 2025-11-27 | SPRINT_0401_0001_0001_reachability_evidence_chain | Attestor Guild · Platform Guild | `src/Attestor/StellaOps.Attestation`, `src/Attestor/StellaOps.Attestor.Envelope` | DsseEnvelopeExtensions added with conversion utilities; Envelope types exposed as transitive dependencies; consumers reference only StellaOps.Attestation. | Need attestor library API freeze | DOAL0101 | | DVOFF-64-002 | TODO | | SPRINT_160_export_evidence | DevPortal Offline Guild | docs/modules/export-center/devportal-offline.md | DevPortal Offline + AirGap Controller Guilds | Needs exporter DSSE schema from 002_ATEL0101 | DEVL0102 | | EDITOR-401-004 | TODO | | SPRINT_0401_0001_0001_reachability_evidence_chain | Docs Guild · CLI Guild | `src/Cli/StellaOps.Cli`, `docs/policy/lifecycle.md` | Gather CLI/editor alignment notes | Gather CLI/editor alignment notes | DOCL0103 | | EMIT-15-001 | TODO | | SPRINT_136_scanner_surface | Docs Guild · Scanner Emit Guild | src/Scanner/__Libraries/StellaOps.Scanner.Emit | Need EntryTrace emit notes from SCANNER-SURFACE-04 | SCANNER-SURFACE-04 | DOEM0101 | diff --git a/docs/modules/cli/guides/attest.md b/docs/modules/cli/guides/attest.md index 6641f3c84..f36df6879 100644 --- a/docs/modules/cli/guides/attest.md +++ b/docs/modules/cli/guides/attest.md @@ -19,6 +19,77 @@ stella attest list --tenant default --issuer dev-kms --format table stella attest show --id a1b2c3 --output json ``` +## CI/CD Integration + +### GitHub Actions + +```yaml +# .github/workflows/verify-attestation.yml +name: Verify Attestation + +on: + workflow_dispatch: + inputs: + artifact_path: + description: 'Path to artifact with attestation' + required: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: signed-artifact + path: ./artifacts + + - name: Install StellaOps CLI + run: | + dotnet tool install --global StellaOps.Cli + + - name: Verify attestation + run: | + stella attest verify \ + --envelope ./artifacts/attestation.dsse.json \ + --policy ./policy/verify-policy.json \ + --root ./keys/trusted-root.pem \ + --output ./verification-report.json + + - name: Upload verification report + uses: actions/upload-artifact@v4 + with: + name: verification-report + path: ./verification-report.json +``` + +### GitLab CI + +```yaml +# .gitlab-ci.yml +verify-attestation: + stage: verify + image: mcr.microsoft.com/dotnet/sdk:10.0 + before_script: + - dotnet tool install --global StellaOps.Cli + - export PATH="$PATH:$HOME/.dotnet/tools" + script: + - | + stella attest verify \ + --envelope ./artifacts/attestation.dsse.json \ + --policy ./policy/verify-policy.json \ + --root ./keys/trusted-root.pem \ + --output ./verification-report.json + artifacts: + paths: + - verification-report.json + expire_in: 1 week + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" +``` + ## Notes - No network access required in sealed mode. - All commands emit deterministic JSON; timestamps in UTC. diff --git a/docs/modules/scanner/deterministic-execution.md b/docs/modules/scanner/deterministic-execution.md index 03ae0d97d..54f7f2d60 100644 --- a/docs/modules/scanner/deterministic-execution.md +++ b/docs/modules/scanner/deterministic-execution.md @@ -10,6 +10,7 @@ This note collects the invariants required for reproducible Scanner runs and rep - Concurrency cap: `scanner:determinism:concurrencyLimit=1` (worker clamps `MaxConcurrentJobs` to this) or `SCANNER__DETERMINISM__CONCURRENCYLIMIT=1`. - Feed/policy pins: `scanner:determinism:feedSnapshotId=` and `scanner:determinism:policySnapshotId=` to stamp submissions and reject mismatched runtime policies. - Log filtering: `scanner:determinism:filterLogs=true` to strip timestamps/PIDs before hashing. +- Evidence: worker emits `determinism.json` into the surface manifest (view `replay`) summarising fixed clock, seed, concurrency cap, feed/policy pins, and per-payload hashes so replay kits can assert settings. ## Ordering - Sort inputs (images, layers, files, findings) deterministically before processing/serialization. diff --git a/out/bench-determinism/bench-determinism-artifacts.tgz b/out/bench-determinism/bench-determinism-artifacts.tgz index f6111b3ad7520a058ff8a3f2f3d9163747850e67..b1b35e513486653a14673405924345474fd03b45 100644 GIT binary patch literal 976 zcmV;>126m^iwFP!000001MQmIj^aiThPg(Xcd+K#n65s1qQ<=5Yv&a|N`+LfU~4xw>t{Taa3`ZECv*MAIHl-@cU3}eK3W292V zSmmju!m>!B*ItyGFv2-k8l*5G%Alh$!3SPu*}C5CyLS29nBA&fwx-%`!s0k8{%E)L zoCzH`0U|9I!GJfsj7kW`wD-Whjn-?C-qIcnvBGlBw9(dpG?E3*E(*=Q?$%X!lQ<8H z+8<@6zL6i4V%datze%@uIF0Y#|D{Z4AjLnGRAT-ggMR%_Q8q*xxV8SAz2E<7TK_b_ z{eKjg=7PD>kw8pIJf&U{u05j!Yy>N$6v1-Mf|Qzi3q}FZn0EBYqk$l^%x&wqeoi~T zGj$!B_O$B@rDDmbwU!WXQwouROIu1WqP3-vAc%BIrKCej6_^GiE#oPp5KbtgeO_tp zwz{O1cIRJD^QTMWE`3@nO^AyDyfBWl(g86Lfq_+ANlQt|tSY^Bo)}t!ELnQAC?Q_v ze+jQ|vu}5yPQ&6?$|rsNpO1xWU+%1NBmTJ*{5t-L!uo#{&QY5m!Yof;{=C}ykf+)z zUzv85$7Fh4KYw=HRBb)XtF8MpzmCQHKf)(Z({AgvGn<^`9-I^Lu(ZAk3l$9!2k9;W?obGV&gqXRx2t`Oo8U(m?k&Sqmd6(EEz_wik1gC= zW>Z-nTe!E(r?Nb@aBo>mWqEAj-m;v^^4P+?Wi^%Mv4wlfdMe9f3-^}GsVv_|c!>J1 zum9V9Rhj0?@yhS3ar5^dCa&v$${5!FWAOJp%O2k~e?B*+3y)736lDI-)E9>g&L#dA zO)zdXz|;Tt^ndtO|A%u982@9?ec6Tdl)nl2>4)Ek`LT8Lmd^SV+od-i zzy7ztBi0BXoZ?dVcdy+*8aL~Id7b|mCs_ZF!gcBY=}`C|qCef1YWIl}Di#0>s(vH# yiO?rOe#bnEAP9mW2!bF8f*=TjAP9mW2!bF8f*=TjAP9mW6Z0=+?l~6#PyhgL_xUpb literal 693 zcmV;m0!sZKiwFP!000001MQg0j+-zTg;_`HJD^#U#J}$zY2Ko%ZbrUNXahu;jHa#n z?sYmBMp3JDqy*HCzXb*v`S|d2knw6=TpW}TLL6EU483ljDS!b0=NK?AP&y^NDB0zw zRuua<=)NqA`)%F2F8=YpQ~m!atF`g1-7K9SeBab<9h;@@hdO9G#MN;3?F<@!Uw?rK z!m0iOV^);t4B97o_x106+;4|?!SBEu@}GJj)gN=5^FIk={p)tOPmzxsjTt|y#;N*K z0!R5jT;%*u0g;put1R+JE95YC0#WG*MqmOM#yRtbO5!<}*cs3Q00-%5ph1J@Wof%M z)OT?mt!`W2$JOW9wF@RhPy`!e5OT&b#XaRlamRu&ig93wTOkOtNC{6QXl@8~o+64c z!Ky4D-^X<{U6YQw*=_wY?%c!U_-Sg?rn{w(2-)DlF>NVP7LbMj1dO198;le&LOE$2 z(pUjk1ZZl*%lP;H;d%6K=i7AoC@^&OD@=rOV$N5K`=loB>Q);V2 z%&PSAS54=9)$iMCqvNIu$#mV`-P&&74j&dxXTMa(S*(5&pDGR8wYAmT3RMoQW#RB~ z@sblynb3hr6%4lAf*`_h;SEcDL>imIjEUDmA!((S24scbq8vB-5Vqb;E<9R>xhzjD zJX*$cS)N*Wv`pr*Jhkv>na*W-YT?l`o6GXl!lPwAm*uI2N6TU^%To)FmgQWQrxqS9 ztGO(nNB9!!zj6H^_f4bwufJw~UX7FcKZG5x|MULeM0{VA<<+ar-`idf{_47f6@Gx9R<7EE%@&7*|g68~B!Ey9&4#6Ml#;3>9>~1i^YzeT${3C{IO0Nn1 b^j@~cGMP*!lgVT> "$OUT/summary.txt" awk -v rate="$det_rate" -v th="$THRESHOLD" 'BEGIN {if (rate+0 < th+0) {printf("determinism_rate %s is below threshold %s\n", rate, th); exit 1}}' +if [ -n "${DET_REACH_GRAPHS:-}" ]; then + echo "[bench-determinism] running reachability dataset hash" + reach_graphs=${DET_REACH_GRAPHS} + reach_runtime=${DET_REACH_RUNTIME:-} + # prefix relative globs with repo root for consistency + case "$reach_graphs" in + /*) ;; + *) reach_graphs="${ROOT}/${reach_graphs}" ;; + esac + case "$reach_runtime" in + /*|"") ;; + *) reach_runtime="${ROOT}/${reach_runtime}" ;; + esac + python run_reachability.py \ + --graphs ${reach_graphs} \ + --runtime ${reach_runtime} \ + --output results + # copy reachability outputs + cp results/results-reach.csv "$OUT"/ || true + cp results/results-reach.json "$OUT"/ || true + cp results/dataset.sha256 "$OUT"/ || true +fi + tar -C "$OUT" -czf "$OUT/bench-determinism-artifacts.tgz" . echo "[bench-determinism] artifacts at $OUT" diff --git a/src/Attestor/StellaOps.Attestation/DsseEnvelopeExtensions.cs b/src/Attestor/StellaOps.Attestation/DsseEnvelopeExtensions.cs new file mode 100644 index 000000000..70930b852 --- /dev/null +++ b/src/Attestor/StellaOps.Attestation/DsseEnvelopeExtensions.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StellaOps.Attestor.Envelope; + +namespace StellaOps.Attestation; + +/// +/// Extension methods for converting between domain types +/// and API DTO representations. +/// +public static class DsseEnvelopeExtensions +{ + /// + /// Converts a to a JSON-serializable dictionary + /// suitable for API responses. + /// + public static Dictionary ToSerializableDict(this DsseEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + + return new Dictionary + { + ["payloadType"] = envelope.PayloadType, + ["payload"] = Convert.ToBase64String(envelope.Payload.Span), + ["signatures"] = envelope.Signatures.Select(s => new Dictionary + { + ["keyid"] = s.KeyId, + ["sig"] = s.Signature + }).ToList() + }; + } + + /// + /// Creates a from base64-encoded payload and signature data. + /// + /// The DSSE payload type URI. + /// Base64-encoded payload bytes. + /// Collection of signature data as (keyId, signatureBase64) tuples. + /// A new instance. + public static DsseEnvelope FromBase64( + string payloadType, + string payloadBase64, + IEnumerable<(string? KeyId, string SignatureBase64)> signatures) + { + ArgumentException.ThrowIfNullOrWhiteSpace(payloadType); + ArgumentException.ThrowIfNullOrWhiteSpace(payloadBase64); + ArgumentNullException.ThrowIfNull(signatures); + + var payloadBytes = Convert.FromBase64String(payloadBase64); + var dsseSignatures = signatures.Select(s => new DsseSignature(s.SignatureBase64, s.KeyId)); + + return new DsseEnvelope(payloadType, payloadBytes, dsseSignatures); + } + + /// + /// Gets the payload as a UTF-8 string. + /// + public static string GetPayloadString(this DsseEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + return System.Text.Encoding.UTF8.GetString(envelope.Payload.Span); + } + + /// + /// Gets the payload as a base64-encoded string. + /// + public static string GetPayloadBase64(this DsseEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(envelope); + return Convert.ToBase64String(envelope.Payload.Span); + } +} diff --git a/src/Attestor/StellaOps.Attestation/DsseHelper.cs b/src/Attestor/StellaOps.Attestation/DsseHelper.cs index 28f7cf4a2..15a102862 100644 --- a/src/Attestor/StellaOps.Attestation/DsseHelper.cs +++ b/src/Attestor/StellaOps.Attestation/DsseHelper.cs @@ -50,6 +50,7 @@ public static class DsseHelper var keyId = await signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); var dsseSignature = DsseSignature.FromBytes(signatureBytes, keyId); - return new DsseEnvelope(statement.Type, payloadBytes, new[] { dsseSignature }); + var payloadType = statement.Type ?? "https://in-toto.io/Statement/v1"; + return new DsseEnvelope(payloadType, payloadBytes, new[] { dsseSignature }); } } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityDsseStatementSigner.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityDsseStatementSigner.cs new file mode 100644 index 000000000..42d8caa38 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthorityDsseStatementSigner.cs @@ -0,0 +1,116 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using StellaOps.Attestation; +using StellaOps.Attestor.Envelope; +using StellaOps.Cryptography; + +namespace StellaOps.Authority.Signing; + +/// +/// Signs In-toto statements as DSSE envelopes using Authority's active signing key. +/// Supports SBOM, Graph, VEX, Replay, and other StellaOps predicate types. +/// +public interface IAuthorityDsseStatementSigner +{ + /// + /// Signs an In-toto statement and returns a DSSE envelope. + /// + /// The In-toto statement to sign. + /// Cancellation token. + /// The signed DSSE envelope containing the statement. + Task SignStatementAsync(InTotoStatement statement, CancellationToken cancellationToken = default); + + /// + /// Gets the key ID of the active signing key. + /// + string? ActiveKeyId { get; } + + /// + /// Indicates whether signing is enabled and configured. + /// + bool IsEnabled { get; } +} + +/// +/// Result of signing an In-toto statement. +/// +/// The signed DSSE envelope. +/// The key ID used for signing. +/// The signing algorithm used. +public sealed record DsseStatementSignResult( + DsseEnvelope Envelope, + string KeyId, + string Algorithm); + +/// +/// Implementation of that uses Authority's +/// signing key manager to sign In-toto statements with DSSE envelopes. +/// +internal sealed class AuthorityDsseStatementSigner : IAuthorityDsseStatementSigner +{ + private readonly AuthoritySigningKeyManager keyManager; + private readonly ICryptoProviderRegistry registry; + private readonly ILogger logger; + + public AuthorityDsseStatementSigner( + AuthoritySigningKeyManager keyManager, + ICryptoProviderRegistry registry, + ILogger logger) + { + this.keyManager = keyManager ?? throw new ArgumentNullException(nameof(keyManager)); + this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string? ActiveKeyId => keyManager.Snapshot.ActiveKeyId; + + public bool IsEnabled => !string.IsNullOrWhiteSpace(keyManager.Snapshot.ActiveKeyId); + + public async Task SignStatementAsync(InTotoStatement statement, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(statement); + + var snapshot = keyManager.Snapshot; + if (string.IsNullOrWhiteSpace(snapshot.ActiveKeyId)) + { + throw new InvalidOperationException("Authority signing is not configured. Enable signing before creating attestations."); + } + + if (string.IsNullOrWhiteSpace(snapshot.ActiveProvider)) + { + throw new InvalidOperationException("Authority signing provider is not configured."); + } + + var signerResolution = registry.ResolveSigner( + CryptoCapability.Signing, + GetAlgorithmForKey(snapshot), + new CryptoKeyReference(snapshot.ActiveKeyId!), + snapshot.ActiveProvider); + + var adapter = new AuthoritySignerAdapter(signerResolution.Signer); + + logger.LogDebug( + "Signing In-toto statement with predicate type {PredicateType} using key {KeyId}.", + statement.PredicateType, + snapshot.ActiveKeyId); + + var envelope = await DsseHelper.WrapAsync(statement, adapter, cancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Created DSSE envelope for predicate type {PredicateType}, key {KeyId}, {SignatureCount} signature(s).", + statement.PredicateType, + snapshot.ActiveKeyId, + envelope.Signatures.Count); + + return envelope; + } + + private static string GetAlgorithmForKey(SigningKeySnapshot snapshot) + { + // Default to ES256 if not explicitly specified + // The AuthoritySigningKeyManager normalises algorithm during load + return SignatureAlgorithms.Es256; + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthoritySignerAdapter.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthoritySignerAdapter.cs new file mode 100644 index 000000000..16591f687 --- /dev/null +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/Signing/AuthoritySignerAdapter.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using StellaOps.Attestation; +using StellaOps.Cryptography; + +namespace StellaOps.Authority.Signing; + +/// +/// Adapts an to the interface +/// used by attestation signing helpers. +/// +internal sealed class AuthoritySignerAdapter : IAuthoritySigner +{ + private readonly ICryptoSigner signer; + + public AuthoritySignerAdapter(ICryptoSigner signer) + { + this.signer = signer ?? throw new ArgumentNullException(nameof(signer)); + } + + public Task GetKeyIdAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(signer.KeyId); + } + + public async Task SignAsync(ReadOnlyMemory paePayload, CancellationToken cancellationToken = default) + { + return await signer.SignAsync(paePayload, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj index dbcbd6f1e..8531b58a2 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj @@ -32,6 +32,7 @@ + diff --git a/src/Bench/StellaOps.Bench/Determinism/.gitignore b/src/Bench/StellaOps.Bench/Determinism/.gitignore new file mode 100644 index 000000000..b45f55965 --- /dev/null +++ b/src/Bench/StellaOps.Bench/Determinism/.gitignore @@ -0,0 +1,2 @@ +results/ +__pycache__/ diff --git a/src/Bench/StellaOps.Bench/Determinism/inputs/graphs/sample-graph.json b/src/Bench/StellaOps.Bench/Determinism/inputs/graphs/sample-graph.json new file mode 100644 index 000000000..4e0d5664a --- /dev/null +++ b/src/Bench/StellaOps.Bench/Determinism/inputs/graphs/sample-graph.json @@ -0,0 +1,11 @@ +{ + "graph": { + "nodes": [ + {"id": "pkg:pypi/demo-lib@1.0.0", "type": "package"}, + {"id": "pkg:generic/demo-cli@0.4.2", "type": "package"} + ], + "edges": [ + {"from": "pkg:generic/demo-cli@0.4.2", "to": "pkg:pypi/demo-lib@1.0.0", "type": "depends_on"} + ] + } +} diff --git a/src/Bench/StellaOps.Bench/Determinism/inputs/runtime/sample-runtime.ndjson b/src/Bench/StellaOps.Bench/Determinism/inputs/runtime/sample-runtime.ndjson new file mode 100644 index 000000000..9c19291c8 --- /dev/null +++ b/src/Bench/StellaOps.Bench/Determinism/inputs/runtime/sample-runtime.ndjson @@ -0,0 +1 @@ +{"event":"call","func":"demo","module":"demo-lib","ts":"2025-11-01T00:00:00Z"} diff --git a/src/Bench/StellaOps.Bench/Determinism/results/inputs.sha256 b/src/Bench/StellaOps.Bench/Determinism/results/inputs.sha256 deleted file mode 100644 index 114160e1a..000000000 --- a/src/Bench/StellaOps.Bench/Determinism/results/inputs.sha256 +++ /dev/null @@ -1,3 +0,0 @@ -38453c9c0e0a90d22d7048d3201bf1b5665eb483e6682db1a7112f8e4f4fa1e6 configs/scanners.json -577f932bbb00dbd596e46b96d5fbb9561506c7730c097e381a6b34de40402329 inputs/sboms/sample-spdx.json -1b54ce4087800cfe1d5ac439c10a1f131b7476b2093b79d8cd0a29169314291f inputs/vex/sample-openvex.json diff --git a/src/Bench/StellaOps.Bench/Determinism/results/results.csv b/src/Bench/StellaOps.Bench/Determinism/results/results.csv deleted file mode 100644 index b689bb8e4..000000000 --- a/src/Bench/StellaOps.Bench/Determinism/results/results.csv +++ /dev/null @@ -1,21 +0,0 @@ -scanner,sbom,vex,mode,run,hash,finding_count -mock,sample-spdx.json,sample-openvex.json,canonical,0,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,0,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,1,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,1,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,2,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,2,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,3,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,3,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,4,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,4,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,5,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,5,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,6,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,6,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,7,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,7,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,8,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,8,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,canonical,9,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 -mock,sample-spdx.json,sample-openvex.json,shuffled,9,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2 diff --git a/src/Bench/StellaOps.Bench/Determinism/results/summary.json b/src/Bench/StellaOps.Bench/Determinism/results/summary.json deleted file mode 100644 index 3d4a4c7cf..000000000 --- a/src/Bench/StellaOps.Bench/Determinism/results/summary.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "determinism_rate": 1.0 -} \ No newline at end of file diff --git a/src/Bench/StellaOps.Bench/Determinism/run_reachability.py b/src/Bench/StellaOps.Bench/Determinism/run_reachability.py new file mode 100644 index 000000000..ce94619cf --- /dev/null +++ b/src/Bench/StellaOps.Bench/Determinism/run_reachability.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Reachability dataset hash helper for optional BENCH-DETERMINISM reachability runs. +- Computes deterministic hashes for graph JSON and runtime NDJSON inputs. +- Emits `results-reach.csv` and `dataset.sha256` in the chosen output directory. +""" +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import glob +from pathlib import Path +from typing import Iterable, List + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def expand_files(patterns: Iterable[str]) -> List[Path]: + files: List[Path] = [] + for pattern in patterns: + if not pattern: + continue + for path_str in sorted(glob.glob(pattern)): + path = Path(path_str) + if path.is_file(): + files.append(path) + return files + + +def hash_files(paths: List[Path]) -> List[tuple[str, str]]: + rows: List[tuple[str, str]] = [] + for path in paths: + rows.append((path.name, sha256_bytes(path.read_bytes()))) + return rows + + +def write_manifest(paths: List[Path], manifest_path: Path) -> None: + lines = [] + for path in sorted(paths, key=lambda p: str(p)): + digest = sha256_bytes(path.read_bytes()) + try: + rel = path.resolve().relative_to(Path.cwd().resolve()) + except ValueError: + rel = path.resolve() + lines.append(f"{digest} {rel.as_posix()}\n") + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text("".join(lines), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Reachability dataset hash helper") + parser.add_argument("--graphs", nargs="*", default=["inputs/graphs/*.json"], help="Glob(s) for graph JSON files") + parser.add_argument("--runtime", nargs="*", default=["inputs/runtime/*.ndjson", "inputs/runtime/*.ndjson.gz"], help="Glob(s) for runtime NDJSON files") + parser.add_argument("--output", default="results", help="Output directory") + args = parser.parse_args() + + graphs = expand_files(args.graphs) + runtime = expand_files(args.runtime) + + if not graphs: + raise SystemExit("No graph inputs found; supply --graphs globs") + + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + dataset_manifest_files = graphs + runtime + write_manifest(dataset_manifest_files, output_dir / "dataset.sha256") + + csv_path = output_dir / "results-reach.csv" + fieldnames = ["type", "file", "sha256"] + with csv_path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for name, digest in hash_files(graphs): + writer.writerow({"type": "graph", "file": name, "sha256": digest}) + for name, digest in hash_files(runtime): + writer.writerow({"type": "runtime", "file": name, "sha256": digest}) + + summary = { + "graphs": len(graphs), + "runtime": len(runtime), + "manifest": "dataset.sha256", + } + (output_dir / "results-reach.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + + print(f"Wrote {csv_path} with {len(graphs)} graph(s) and {len(runtime)} runtime file(s)") + + +if __name__ == "__main__": + main() diff --git a/src/Bench/StellaOps.Bench/Determinism/tests/test_run_reachability.py b/src/Bench/StellaOps.Bench/Determinism/tests/test_run_reachability.py new file mode 100644 index 000000000..b841f655a --- /dev/null +++ b/src/Bench/StellaOps.Bench/Determinism/tests/test_run_reachability.py @@ -0,0 +1,33 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +HARNESS_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(HARNESS_DIR)) + +import run_reachability # noqa: E402 + + +class ReachabilityBenchTests(unittest.TestCase): + def setUp(self): + self.graphs = [HARNESS_DIR / "inputs" / "graphs" / "sample-graph.json"] + self.runtime = [HARNESS_DIR / "inputs" / "runtime" / "sample-runtime.ndjson"] + + def test_manifest_includes_files(self): + with TemporaryDirectory() as tmp: + out_dir = Path(tmp) + manifest_path = out_dir / "dataset.sha256" + run_reachability.write_manifest(self.graphs + self.runtime, manifest_path) + text = manifest_path.read_text(encoding="utf-8") + self.assertIn("sample-graph.json", text) + self.assertIn("sample-runtime.ndjson", text) + + def test_hash_files(self): + hashes = dict(run_reachability.hash_files(self.graphs)) + self.assertIn("sample-graph.json", hashes) + self.assertEqual(len(hashes), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs index 26c5cc917..42e4bb38f 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs @@ -4,8 +4,8 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Cli.Configuration; -using StellaOps.Cli.Plugins; -using StellaOps.Cli.Services.Models.AdvisoryAi; +using StellaOps.Cli.Plugins; +using StellaOps.Cli.Services.Models.AdvisoryAi; namespace StellaOps.Cli.Commands; @@ -28,23 +28,24 @@ internal static class CommandFactory { TreatUnmatchedTokensAsErrors = true }; - root.Add(verboseOption); - - root.Add(BuildScannerCommand(services, verboseOption, cancellationToken)); - root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken)); - root.Add(BuildRubyCommand(services, verboseOption, cancellationToken)); - root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken)); - root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken)); - root.Add(BuildAocCommand(services, verboseOption, cancellationToken)); - root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken)); - root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken)); - root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken)); - root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken)); - root.Add(BuildAdviseCommand(services, options, verboseOption, cancellationToken)); - root.Add(BuildConfigCommand(options)); - root.Add(BuildKmsCommand(services, verboseOption, cancellationToken)); - root.Add(BuildVulnCommand(services, verboseOption, cancellationToken)); - root.Add(BuildCryptoCommand(services, verboseOption, cancellationToken)); + root.Add(verboseOption); + + root.Add(BuildScannerCommand(services, verboseOption, cancellationToken)); + root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken)); + root.Add(BuildRubyCommand(services, verboseOption, cancellationToken)); + root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken)); + root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken)); + root.Add(BuildAocCommand(services, verboseOption, cancellationToken)); + root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken)); + root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken)); + root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken)); + root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken)); + root.Add(BuildAdviseCommand(services, options, verboseOption, cancellationToken)); + root.Add(BuildConfigCommand(options)); + root.Add(BuildKmsCommand(services, verboseOption, cancellationToken)); + root.Add(BuildVulnCommand(services, verboseOption, cancellationToken)); + root.Add(BuildCryptoCommand(services, verboseOption, cancellationToken)); + root.Add(BuildAttestCommand(services, verboseOption, cancellationToken)); var pluginLogger = loggerFactory.CreateLogger(); var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger); @@ -178,82 +179,82 @@ internal static class CommandFactory scan.Add(entryTrace); scan.Add(run); - scan.Add(upload); - return scan; - } - - private static Command BuildRubyCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) - { - var ruby = new Command("ruby", "Work with Ruby analyzer outputs."); - - var inspect = new Command("inspect", "Inspect a local Ruby workspace."); - var inspectRootOption = new Option("--root") - { - Description = "Path to the Ruby workspace (defaults to current directory)." - }; - var inspectFormatOption = new Option("--format") - { - Description = "Output format (table or json)." - }; - - inspect.Add(inspectRootOption); - inspect.Add(inspectFormatOption); - inspect.SetAction((parseResult, _) => - { - var root = parseResult.GetValue(inspectRootOption); - var format = parseResult.GetValue(inspectFormatOption) ?? "table"; - var verbose = parseResult.GetValue(verboseOption); - - return CommandHandlers.HandleRubyInspectAsync( - services, - root, - format, - verbose, - cancellationToken); - }); - - var resolve = new Command("resolve", "Fetch Ruby packages for a completed scan."); - var resolveImageOption = new Option("--image") - { - Description = "Image reference (digest or tag) used by the scan." - }; - var resolveScanIdOption = new Option("--scan-id") - { - Description = "Explicit scan identifier." - }; - var resolveFormatOption = new Option("--format") - { - Description = "Output format (table or json)." - }; - - resolve.Add(resolveImageOption); - resolve.Add(resolveScanIdOption); - resolve.Add(resolveFormatOption); - resolve.SetAction((parseResult, _) => - { - var image = parseResult.GetValue(resolveImageOption); - var scanId = parseResult.GetValue(resolveScanIdOption); - var format = parseResult.GetValue(resolveFormatOption) ?? "table"; - var verbose = parseResult.GetValue(verboseOption); - - return CommandHandlers.HandleRubyResolveAsync( - services, - image, - scanId, - format, - verbose, - cancellationToken); - }); - - ruby.Add(inspect); - ruby.Add(resolve); - return ruby; - } - - private static Command BuildKmsCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) - { - var kms = new Command("kms", "Manage file-backed signing keys."); - + scan.Add(upload); + return scan; + } + + private static Command BuildRubyCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { + var ruby = new Command("ruby", "Work with Ruby analyzer outputs."); + + var inspect = new Command("inspect", "Inspect a local Ruby workspace."); + var inspectRootOption = new Option("--root") + { + Description = "Path to the Ruby workspace (defaults to current directory)." + }; + var inspectFormatOption = new Option("--format") + { + Description = "Output format (table or json)." + }; + + inspect.Add(inspectRootOption); + inspect.Add(inspectFormatOption); + inspect.SetAction((parseResult, _) => + { + var root = parseResult.GetValue(inspectRootOption); + var format = parseResult.GetValue(inspectFormatOption) ?? "table"; + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleRubyInspectAsync( + services, + root, + format, + verbose, + cancellationToken); + }); + + var resolve = new Command("resolve", "Fetch Ruby packages for a completed scan."); + var resolveImageOption = new Option("--image") + { + Description = "Image reference (digest or tag) used by the scan." + }; + var resolveScanIdOption = new Option("--scan-id") + { + Description = "Explicit scan identifier." + }; + var resolveFormatOption = new Option("--format") + { + Description = "Output format (table or json)." + }; + + resolve.Add(resolveImageOption); + resolve.Add(resolveScanIdOption); + resolve.Add(resolveFormatOption); + resolve.SetAction((parseResult, _) => + { + var image = parseResult.GetValue(resolveImageOption); + var scanId = parseResult.GetValue(resolveScanIdOption); + var format = parseResult.GetValue(resolveFormatOption) ?? "table"; + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleRubyResolveAsync( + services, + image, + scanId, + format, + verbose, + cancellationToken); + }); + + ruby.Add(inspect); + ruby.Add(resolve); + return ruby; + } + + private static Command BuildKmsCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { + var kms = new Command("kms", "Manage file-backed signing keys."); + var export = new Command("export", "Export key material to a portable bundle."); var exportRootOption = new Option("--root") { @@ -451,39 +452,39 @@ internal static class CommandFactory db.Add(fetch); db.Add(merge); - db.Add(export); - return db; - } - - private static Command BuildCryptoCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) - { - var crypto = new Command("crypto", "Inspect StellaOps cryptography providers."); - var providers = new Command("providers", "List registered crypto providers and keys."); - - var jsonOption = new Option("--json") - { - Description = "Emit JSON output." - }; - - var profileOption = new Option("--profile") - { - Description = "Temporarily override the active registry profile when computing provider order." - }; - - providers.Add(jsonOption); - providers.Add(profileOption); - - providers.SetAction((parseResult, _) => - { - var json = parseResult.GetValue(jsonOption); - var verbose = parseResult.GetValue(verboseOption); - var profile = parseResult.GetValue(profileOption); - return CommandHandlers.HandleCryptoProvidersAsync(services, verbose, json, profile, cancellationToken); - }); - - crypto.Add(providers); - return crypto; - } + db.Add(export); + return db; + } + + private static Command BuildCryptoCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { + var crypto = new Command("crypto", "Inspect StellaOps cryptography providers."); + var providers = new Command("providers", "List registered crypto providers and keys."); + + var jsonOption = new Option("--json") + { + Description = "Emit JSON output." + }; + + var profileOption = new Option("--profile") + { + Description = "Temporarily override the active registry profile when computing provider order." + }; + + providers.Add(jsonOption); + providers.Add(profileOption); + + providers.SetAction((parseResult, _) => + { + var json = parseResult.GetValue(jsonOption); + var verbose = parseResult.GetValue(verboseOption); + var profile = parseResult.GetValue(profileOption); + return CommandHandlers.HandleCryptoProvidersAsync(services, verbose, json, profile, cancellationToken); + }); + + crypto.Add(providers); + return crypto; + } private static Command BuildSourcesCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { @@ -832,11 +833,11 @@ internal static class CommandFactory }; activate.Add(activatePolicyIdArgument); - var activateVersionOption = new Option("--version") - { - Description = "Revision version to activate.", - Arity = ArgumentArity.ExactlyOne - }; + var activateVersionOption = new Option("--version") + { + Description = "Revision version to activate.", + Arity = ArgumentArity.ExactlyOne + }; var activationNoteOption = new Option("--note") { @@ -911,11 +912,11 @@ internal static class CommandFactory var taskRunner = new Command("task-runner", "Interact with Task Runner operations."); var simulate = new Command("simulate", "Simulate a task pack and inspect the execution graph."); - var manifestOption = new Option("--manifest") - { - Description = "Path to the task pack manifest (YAML).", - Arity = ArgumentArity.ExactlyOne - }; + var manifestOption = new Option("--manifest") + { + Description = "Path to the task pack manifest (YAML).", + Arity = ArgumentArity.ExactlyOne + }; var inputsOption = new Option("--inputs") { Description = "Optional JSON file containing Task Pack input values." @@ -1144,336 +1145,336 @@ internal static class CommandFactory cancellationToken); }); - findings.Add(list); - findings.Add(get); - findings.Add(explain); - return findings; - } - - private static Command BuildAdviseCommand(IServiceProvider services, StellaOpsCliOptions options, Option verboseOption, CancellationToken cancellationToken) - { - var advise = new Command("advise", "Interact with Advisory AI pipelines."); - _ = options; - - var runOptions = CreateAdvisoryOptions(); - var runTaskArgument = new Argument("task") - { - Description = "Task to run (summary, conflict, remediation)." - }; - - var run = new Command("run", "Generate Advisory AI output for the specified task."); - run.Add(runTaskArgument); - AddAdvisoryOptions(run, runOptions); - - run.SetAction((parseResult, _) => - { - var taskValue = parseResult.GetValue(runTaskArgument); - var advisoryKey = parseResult.GetValue(runOptions.AdvisoryKey) ?? string.Empty; - var artifactId = parseResult.GetValue(runOptions.ArtifactId); - var artifactPurl = parseResult.GetValue(runOptions.ArtifactPurl); - var policyVersion = parseResult.GetValue(runOptions.PolicyVersion); - var profile = parseResult.GetValue(runOptions.Profile) ?? "default"; - var sections = parseResult.GetValue(runOptions.Sections) ?? Array.Empty(); - var forceRefresh = parseResult.GetValue(runOptions.ForceRefresh); - var timeoutSeconds = parseResult.GetValue(runOptions.TimeoutSeconds) ?? 120; - var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(runOptions.Format)); - var outputPath = parseResult.GetValue(runOptions.Output); - var verbose = parseResult.GetValue(verboseOption); - - if (!Enum.TryParse(taskValue, ignoreCase: true, out var taskType)) - { - throw new InvalidOperationException($"Unknown advisory task '{taskValue}'. Expected summary, conflict, or remediation."); - } - - return CommandHandlers.HandleAdviseRunAsync( - services, - taskType, - advisoryKey, - artifactId, - artifactPurl, - policyVersion, - profile, - sections, - forceRefresh, - timeoutSeconds, - outputFormat, - outputPath, - verbose, - cancellationToken); - }); - - var summarizeOptions = CreateAdvisoryOptions(); - var summarize = new Command("summarize", "Summarize an advisory with JSON/Markdown outputs and citations."); - AddAdvisoryOptions(summarize, summarizeOptions); - summarize.SetAction((parseResult, _) => - { - var advisoryKey = parseResult.GetValue(summarizeOptions.AdvisoryKey) ?? string.Empty; - var artifactId = parseResult.GetValue(summarizeOptions.ArtifactId); - var artifactPurl = parseResult.GetValue(summarizeOptions.ArtifactPurl); - var policyVersion = parseResult.GetValue(summarizeOptions.PolicyVersion); - var profile = parseResult.GetValue(summarizeOptions.Profile) ?? "default"; - var sections = parseResult.GetValue(summarizeOptions.Sections) ?? Array.Empty(); - var forceRefresh = parseResult.GetValue(summarizeOptions.ForceRefresh); - var timeoutSeconds = parseResult.GetValue(summarizeOptions.TimeoutSeconds) ?? 120; - var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(summarizeOptions.Format)); - var outputPath = parseResult.GetValue(summarizeOptions.Output); - var verbose = parseResult.GetValue(verboseOption); - - return CommandHandlers.HandleAdviseRunAsync( - services, - AdvisoryAiTaskType.Summary, - advisoryKey, - artifactId, - artifactPurl, - policyVersion, - profile, - sections, - forceRefresh, - timeoutSeconds, - outputFormat, - outputPath, - verbose, - cancellationToken); - }); - - var explainOptions = CreateAdvisoryOptions(); - var explain = new Command("explain", "Explain an advisory conflict set with narrative and rationale."); - AddAdvisoryOptions(explain, explainOptions); - explain.SetAction((parseResult, _) => - { - var advisoryKey = parseResult.GetValue(explainOptions.AdvisoryKey) ?? string.Empty; - var artifactId = parseResult.GetValue(explainOptions.ArtifactId); - var artifactPurl = parseResult.GetValue(explainOptions.ArtifactPurl); - var policyVersion = parseResult.GetValue(explainOptions.PolicyVersion); - var profile = parseResult.GetValue(explainOptions.Profile) ?? "default"; - var sections = parseResult.GetValue(explainOptions.Sections) ?? Array.Empty(); - var forceRefresh = parseResult.GetValue(explainOptions.ForceRefresh); - var timeoutSeconds = parseResult.GetValue(explainOptions.TimeoutSeconds) ?? 120; - var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(explainOptions.Format)); - var outputPath = parseResult.GetValue(explainOptions.Output); - var verbose = parseResult.GetValue(verboseOption); - - return CommandHandlers.HandleAdviseRunAsync( - services, - AdvisoryAiTaskType.Conflict, - advisoryKey, - artifactId, - artifactPurl, - policyVersion, - profile, - sections, - forceRefresh, - timeoutSeconds, - outputFormat, - outputPath, - verbose, - cancellationToken); - }); - - var remediateOptions = CreateAdvisoryOptions(); - var remediate = new Command("remediate", "Generate remediation guidance for an advisory."); - AddAdvisoryOptions(remediate, remediateOptions); - remediate.SetAction((parseResult, _) => - { - var advisoryKey = parseResult.GetValue(remediateOptions.AdvisoryKey) ?? string.Empty; - var artifactId = parseResult.GetValue(remediateOptions.ArtifactId); - var artifactPurl = parseResult.GetValue(remediateOptions.ArtifactPurl); - var policyVersion = parseResult.GetValue(remediateOptions.PolicyVersion); - var profile = parseResult.GetValue(remediateOptions.Profile) ?? "default"; - var sections = parseResult.GetValue(remediateOptions.Sections) ?? Array.Empty(); - var forceRefresh = parseResult.GetValue(remediateOptions.ForceRefresh); - var timeoutSeconds = parseResult.GetValue(remediateOptions.TimeoutSeconds) ?? 120; - var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(remediateOptions.Format)); - var outputPath = parseResult.GetValue(remediateOptions.Output); - var verbose = parseResult.GetValue(verboseOption); - - return CommandHandlers.HandleAdviseRunAsync( - services, - AdvisoryAiTaskType.Remediation, - advisoryKey, - artifactId, - artifactPurl, - policyVersion, - profile, - sections, - forceRefresh, - timeoutSeconds, - outputFormat, - outputPath, - verbose, - cancellationToken); - }); - - var batchOptions = CreateAdvisoryOptions(); - var batchKeys = new Argument("advisory-keys") - { - Description = "One or more advisory identifiers.", - Arity = ArgumentArity.OneOrMore - }; - var batch = new Command("batch", "Run Advisory AI over multiple advisories with a single invocation."); - batch.Add(batchKeys); - batch.Add(batchOptions.Output); - batch.Add(batchOptions.AdvisoryKey); - batch.Add(batchOptions.ArtifactId); - batch.Add(batchOptions.ArtifactPurl); - batch.Add(batchOptions.PolicyVersion); - batch.Add(batchOptions.Profile); - batch.Add(batchOptions.Sections); - batch.Add(batchOptions.ForceRefresh); - batch.Add(batchOptions.TimeoutSeconds); - batch.Add(batchOptions.Format); - batch.SetAction((parseResult, _) => - { - var advisoryKeys = parseResult.GetValue(batchKeys) ?? Array.Empty(); - var artifactId = parseResult.GetValue(batchOptions.ArtifactId); - var artifactPurl = parseResult.GetValue(batchOptions.ArtifactPurl); - var policyVersion = parseResult.GetValue(batchOptions.PolicyVersion); - var profile = parseResult.GetValue(batchOptions.Profile) ?? "default"; - var sections = parseResult.GetValue(batchOptions.Sections) ?? Array.Empty(); - var forceRefresh = parseResult.GetValue(batchOptions.ForceRefresh); - var timeoutSeconds = parseResult.GetValue(batchOptions.TimeoutSeconds) ?? 120; - var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(batchOptions.Format)); - var outputDirectory = parseResult.GetValue(batchOptions.Output); - var verbose = parseResult.GetValue(verboseOption); - - return CommandHandlers.HandleAdviseBatchAsync( - services, - AdvisoryAiTaskType.Summary, - advisoryKeys, - artifactId, - artifactPurl, - policyVersion, - profile, - sections, - forceRefresh, - timeoutSeconds, - outputFormat, - outputDirectory, - verbose, - cancellationToken); - }); - - advise.Add(run); - advise.Add(summarize); - advise.Add(explain); - advise.Add(remediate); - advise.Add(batch); - return advise; - } - - private static AdvisoryCommandOptions CreateAdvisoryOptions() - { - var advisoryKey = new Option("--advisory-key") - { - Description = "Advisory identifier to summarise (required).", - Required = true - }; - - var artifactId = new Option("--artifact-id") - { - Description = "Optional artifact identifier to scope SBOM context." - }; - - var artifactPurl = new Option("--artifact-purl") - { - Description = "Optional package URL to scope dependency context." - }; - - var policyVersion = new Option("--policy-version") - { - Description = "Policy revision to evaluate (defaults to current)." - }; - - var profile = new Option("--profile") - { - Description = "Advisory AI execution profile (default, fips-local, etc.)." - }; - - var sections = new Option("--section") - { - Description = "Preferred context sections to emphasise (repeatable).", - Arity = ArgumentArity.ZeroOrMore - }; - sections.AllowMultipleArgumentsPerToken = true; - - var forceRefresh = new Option("--force-refresh") - { - Description = "Bypass cached plan/output and recompute." - }; - - var timeoutSeconds = new Option("--timeout") - { - Description = "Seconds to wait for generated output before timing out (0 = single attempt)." - }; - timeoutSeconds.Arity = ArgumentArity.ZeroOrOne; - - var format = new Option("--format") - { - Description = "Output format: table (default), json, or markdown." - }; - - var output = new Option("--output") - { - Description = "File path to write advisory output when using json/markdown formats." - }; - - return new AdvisoryCommandOptions( - advisoryKey, - artifactId, - artifactPurl, - policyVersion, - profile, - sections, - forceRefresh, - timeoutSeconds, - format, - output); - } - - private static void AddAdvisoryOptions(Command command, AdvisoryCommandOptions options) - { - command.Add(options.AdvisoryKey); - command.Add(options.ArtifactId); - command.Add(options.ArtifactPurl); - command.Add(options.PolicyVersion); - command.Add(options.Profile); - command.Add(options.Sections); - command.Add(options.ForceRefresh); - command.Add(options.TimeoutSeconds); - command.Add(options.Format); - command.Add(options.Output); - } - - private static AdvisoryOutputFormat ParseAdvisoryOutputFormat(string? formatValue) - { - var normalized = string.IsNullOrWhiteSpace(formatValue) - ? "table" - : formatValue!.Trim().ToLowerInvariant(); - - return normalized switch - { - "json" => AdvisoryOutputFormat.Json, - "markdown" => AdvisoryOutputFormat.Markdown, - "md" => AdvisoryOutputFormat.Markdown, - _ => AdvisoryOutputFormat.Table - }; - } - - private sealed record AdvisoryCommandOptions( - Option AdvisoryKey, - Option ArtifactId, - Option ArtifactPurl, - Option PolicyVersion, - Option Profile, - Option Sections, - Option ForceRefresh, - Option TimeoutSeconds, - Option Format, - Option Output); - - private static Command BuildVulnCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) - { - var vuln = new Command("vuln", "Explore vulnerability observations and overlays."); + findings.Add(list); + findings.Add(get); + findings.Add(explain); + return findings; + } + + private static Command BuildAdviseCommand(IServiceProvider services, StellaOpsCliOptions options, Option verboseOption, CancellationToken cancellationToken) + { + var advise = new Command("advise", "Interact with Advisory AI pipelines."); + _ = options; + + var runOptions = CreateAdvisoryOptions(); + var runTaskArgument = new Argument("task") + { + Description = "Task to run (summary, conflict, remediation)." + }; + + var run = new Command("run", "Generate Advisory AI output for the specified task."); + run.Add(runTaskArgument); + AddAdvisoryOptions(run, runOptions); + + run.SetAction((parseResult, _) => + { + var taskValue = parseResult.GetValue(runTaskArgument); + var advisoryKey = parseResult.GetValue(runOptions.AdvisoryKey) ?? string.Empty; + var artifactId = parseResult.GetValue(runOptions.ArtifactId); + var artifactPurl = parseResult.GetValue(runOptions.ArtifactPurl); + var policyVersion = parseResult.GetValue(runOptions.PolicyVersion); + var profile = parseResult.GetValue(runOptions.Profile) ?? "default"; + var sections = parseResult.GetValue(runOptions.Sections) ?? Array.Empty(); + var forceRefresh = parseResult.GetValue(runOptions.ForceRefresh); + var timeoutSeconds = parseResult.GetValue(runOptions.TimeoutSeconds) ?? 120; + var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(runOptions.Format)); + var outputPath = parseResult.GetValue(runOptions.Output); + var verbose = parseResult.GetValue(verboseOption); + + if (!Enum.TryParse(taskValue, ignoreCase: true, out var taskType)) + { + throw new InvalidOperationException($"Unknown advisory task '{taskValue}'. Expected summary, conflict, or remediation."); + } + + return CommandHandlers.HandleAdviseRunAsync( + services, + taskType, + advisoryKey, + artifactId, + artifactPurl, + policyVersion, + profile, + sections, + forceRefresh, + timeoutSeconds, + outputFormat, + outputPath, + verbose, + cancellationToken); + }); + + var summarizeOptions = CreateAdvisoryOptions(); + var summarize = new Command("summarize", "Summarize an advisory with JSON/Markdown outputs and citations."); + AddAdvisoryOptions(summarize, summarizeOptions); + summarize.SetAction((parseResult, _) => + { + var advisoryKey = parseResult.GetValue(summarizeOptions.AdvisoryKey) ?? string.Empty; + var artifactId = parseResult.GetValue(summarizeOptions.ArtifactId); + var artifactPurl = parseResult.GetValue(summarizeOptions.ArtifactPurl); + var policyVersion = parseResult.GetValue(summarizeOptions.PolicyVersion); + var profile = parseResult.GetValue(summarizeOptions.Profile) ?? "default"; + var sections = parseResult.GetValue(summarizeOptions.Sections) ?? Array.Empty(); + var forceRefresh = parseResult.GetValue(summarizeOptions.ForceRefresh); + var timeoutSeconds = parseResult.GetValue(summarizeOptions.TimeoutSeconds) ?? 120; + var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(summarizeOptions.Format)); + var outputPath = parseResult.GetValue(summarizeOptions.Output); + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleAdviseRunAsync( + services, + AdvisoryAiTaskType.Summary, + advisoryKey, + artifactId, + artifactPurl, + policyVersion, + profile, + sections, + forceRefresh, + timeoutSeconds, + outputFormat, + outputPath, + verbose, + cancellationToken); + }); + + var explainOptions = CreateAdvisoryOptions(); + var explain = new Command("explain", "Explain an advisory conflict set with narrative and rationale."); + AddAdvisoryOptions(explain, explainOptions); + explain.SetAction((parseResult, _) => + { + var advisoryKey = parseResult.GetValue(explainOptions.AdvisoryKey) ?? string.Empty; + var artifactId = parseResult.GetValue(explainOptions.ArtifactId); + var artifactPurl = parseResult.GetValue(explainOptions.ArtifactPurl); + var policyVersion = parseResult.GetValue(explainOptions.PolicyVersion); + var profile = parseResult.GetValue(explainOptions.Profile) ?? "default"; + var sections = parseResult.GetValue(explainOptions.Sections) ?? Array.Empty(); + var forceRefresh = parseResult.GetValue(explainOptions.ForceRefresh); + var timeoutSeconds = parseResult.GetValue(explainOptions.TimeoutSeconds) ?? 120; + var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(explainOptions.Format)); + var outputPath = parseResult.GetValue(explainOptions.Output); + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleAdviseRunAsync( + services, + AdvisoryAiTaskType.Conflict, + advisoryKey, + artifactId, + artifactPurl, + policyVersion, + profile, + sections, + forceRefresh, + timeoutSeconds, + outputFormat, + outputPath, + verbose, + cancellationToken); + }); + + var remediateOptions = CreateAdvisoryOptions(); + var remediate = new Command("remediate", "Generate remediation guidance for an advisory."); + AddAdvisoryOptions(remediate, remediateOptions); + remediate.SetAction((parseResult, _) => + { + var advisoryKey = parseResult.GetValue(remediateOptions.AdvisoryKey) ?? string.Empty; + var artifactId = parseResult.GetValue(remediateOptions.ArtifactId); + var artifactPurl = parseResult.GetValue(remediateOptions.ArtifactPurl); + var policyVersion = parseResult.GetValue(remediateOptions.PolicyVersion); + var profile = parseResult.GetValue(remediateOptions.Profile) ?? "default"; + var sections = parseResult.GetValue(remediateOptions.Sections) ?? Array.Empty(); + var forceRefresh = parseResult.GetValue(remediateOptions.ForceRefresh); + var timeoutSeconds = parseResult.GetValue(remediateOptions.TimeoutSeconds) ?? 120; + var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(remediateOptions.Format)); + var outputPath = parseResult.GetValue(remediateOptions.Output); + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleAdviseRunAsync( + services, + AdvisoryAiTaskType.Remediation, + advisoryKey, + artifactId, + artifactPurl, + policyVersion, + profile, + sections, + forceRefresh, + timeoutSeconds, + outputFormat, + outputPath, + verbose, + cancellationToken); + }); + + var batchOptions = CreateAdvisoryOptions(); + var batchKeys = new Argument("advisory-keys") + { + Description = "One or more advisory identifiers.", + Arity = ArgumentArity.OneOrMore + }; + var batch = new Command("batch", "Run Advisory AI over multiple advisories with a single invocation."); + batch.Add(batchKeys); + batch.Add(batchOptions.Output); + batch.Add(batchOptions.AdvisoryKey); + batch.Add(batchOptions.ArtifactId); + batch.Add(batchOptions.ArtifactPurl); + batch.Add(batchOptions.PolicyVersion); + batch.Add(batchOptions.Profile); + batch.Add(batchOptions.Sections); + batch.Add(batchOptions.ForceRefresh); + batch.Add(batchOptions.TimeoutSeconds); + batch.Add(batchOptions.Format); + batch.SetAction((parseResult, _) => + { + var advisoryKeys = parseResult.GetValue(batchKeys) ?? Array.Empty(); + var artifactId = parseResult.GetValue(batchOptions.ArtifactId); + var artifactPurl = parseResult.GetValue(batchOptions.ArtifactPurl); + var policyVersion = parseResult.GetValue(batchOptions.PolicyVersion); + var profile = parseResult.GetValue(batchOptions.Profile) ?? "default"; + var sections = parseResult.GetValue(batchOptions.Sections) ?? Array.Empty(); + var forceRefresh = parseResult.GetValue(batchOptions.ForceRefresh); + var timeoutSeconds = parseResult.GetValue(batchOptions.TimeoutSeconds) ?? 120; + var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(batchOptions.Format)); + var outputDirectory = parseResult.GetValue(batchOptions.Output); + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleAdviseBatchAsync( + services, + AdvisoryAiTaskType.Summary, + advisoryKeys, + artifactId, + artifactPurl, + policyVersion, + profile, + sections, + forceRefresh, + timeoutSeconds, + outputFormat, + outputDirectory, + verbose, + cancellationToken); + }); + + advise.Add(run); + advise.Add(summarize); + advise.Add(explain); + advise.Add(remediate); + advise.Add(batch); + return advise; + } + + private static AdvisoryCommandOptions CreateAdvisoryOptions() + { + var advisoryKey = new Option("--advisory-key") + { + Description = "Advisory identifier to summarise (required).", + Required = true + }; + + var artifactId = new Option("--artifact-id") + { + Description = "Optional artifact identifier to scope SBOM context." + }; + + var artifactPurl = new Option("--artifact-purl") + { + Description = "Optional package URL to scope dependency context." + }; + + var policyVersion = new Option("--policy-version") + { + Description = "Policy revision to evaluate (defaults to current)." + }; + + var profile = new Option("--profile") + { + Description = "Advisory AI execution profile (default, fips-local, etc.)." + }; + + var sections = new Option("--section") + { + Description = "Preferred context sections to emphasise (repeatable).", + Arity = ArgumentArity.ZeroOrMore + }; + sections.AllowMultipleArgumentsPerToken = true; + + var forceRefresh = new Option("--force-refresh") + { + Description = "Bypass cached plan/output and recompute." + }; + + var timeoutSeconds = new Option("--timeout") + { + Description = "Seconds to wait for generated output before timing out (0 = single attempt)." + }; + timeoutSeconds.Arity = ArgumentArity.ZeroOrOne; + + var format = new Option("--format") + { + Description = "Output format: table (default), json, or markdown." + }; + + var output = new Option("--output") + { + Description = "File path to write advisory output when using json/markdown formats." + }; + + return new AdvisoryCommandOptions( + advisoryKey, + artifactId, + artifactPurl, + policyVersion, + profile, + sections, + forceRefresh, + timeoutSeconds, + format, + output); + } + + private static void AddAdvisoryOptions(Command command, AdvisoryCommandOptions options) + { + command.Add(options.AdvisoryKey); + command.Add(options.ArtifactId); + command.Add(options.ArtifactPurl); + command.Add(options.PolicyVersion); + command.Add(options.Profile); + command.Add(options.Sections); + command.Add(options.ForceRefresh); + command.Add(options.TimeoutSeconds); + command.Add(options.Format); + command.Add(options.Output); + } + + private static AdvisoryOutputFormat ParseAdvisoryOutputFormat(string? formatValue) + { + var normalized = string.IsNullOrWhiteSpace(formatValue) + ? "table" + : formatValue!.Trim().ToLowerInvariant(); + + return normalized switch + { + "json" => AdvisoryOutputFormat.Json, + "markdown" => AdvisoryOutputFormat.Markdown, + "md" => AdvisoryOutputFormat.Markdown, + _ => AdvisoryOutputFormat.Table + }; + } + + private sealed record AdvisoryCommandOptions( + Option AdvisoryKey, + Option ArtifactId, + Option ArtifactPurl, + Option PolicyVersion, + Option Profile, + Option Sections, + Option ForceRefresh, + Option TimeoutSeconds, + Option Format, + Option Output); + + private static Command BuildVulnCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { + var vuln = new Command("vuln", "Explore vulnerability observations and overlays."); var observations = new Command("observations", "List raw advisory observations for overlay consumers."); @@ -1607,4 +1608,122 @@ internal static class CommandFactory _ => $"{value[..2]}***{value[^2..]}" }; } + + private static Command BuildAttestCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { + var attest = new Command("attest", "Verify and inspect DSSE attestations."); + + // attest verify + var verify = new Command("verify", "Verify a DSSE envelope offline against policy and trust roots."); + var envelopeOption = new Option("--envelope", new[] { "-e" }) + { + Description = "Path to the DSSE envelope file (JSON or sigstore bundle).", + Required = true + }; + var policyOption = new Option("--policy") + { + Description = "Path to policy JSON file for verification rules." + }; + var rootOption = new Option("--root") + { + Description = "Path to trusted root certificate (PEM format)." + }; + var checkpointOption = new Option("--transparency-checkpoint") + { + Description = "Path to Rekor transparency checkpoint file." + }; + var verifyOutputOption = new Option("--output", new[] { "-o" }) + { + Description = "Output path for verification report." + }; + + verify.Add(envelopeOption); + verify.Add(policyOption); + verify.Add(rootOption); + verify.Add(checkpointOption); + verify.Add(verifyOutputOption); + + verify.SetAction((parseResult, _) => + { + var envelope = parseResult.GetValue(envelopeOption)!; + var policy = parseResult.GetValue(policyOption); + var root = parseResult.GetValue(rootOption); + var checkpoint = parseResult.GetValue(checkpointOption); + var output = parseResult.GetValue(verifyOutputOption); + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleAttestVerifyAsync(services, envelope, policy, root, checkpoint, output, verbose, cancellationToken); + }); + + // attest list + var list = new Command("list", "List attestations from the backend."); + var tenantOption = new Option("--tenant") + { + Description = "Tenant identifier to filter by." + }; + var issuerOption = new Option("--issuer") + { + Description = "Issuer identifier to filter by." + }; + var formatOption = new Option("--format", new[] { "-f" }) + { + Description = "Output format (table, json)." + }; + var limitOption = new Option("--limit", new[] { "-n" }) + { + Description = "Maximum number of results to return." + }; + + list.Add(tenantOption); + list.Add(issuerOption); + list.Add(formatOption); + list.Add(limitOption); + + list.SetAction((parseResult, _) => + { + var tenant = parseResult.GetValue(tenantOption); + var issuer = parseResult.GetValue(issuerOption); + var format = parseResult.GetValue(formatOption) ?? "table"; + var limit = parseResult.GetValue(limitOption); + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleAttestListAsync(services, tenant, issuer, format, limit, verbose, cancellationToken); + }); + + // attest show + var show = new Command("show", "Display details for a specific attestation."); + var idOption = new Option("--id") + { + Description = "Attestation identifier.", + Required = true + }; + var showOutputOption = new Option("--output", new[] { "-o" }) + { + Description = "Output format (json, table)." + }; + var includeProofOption = new Option("--include-proof") + { + Description = "Include Rekor inclusion proof in output." + }; + + show.Add(idOption); + show.Add(showOutputOption); + show.Add(includeProofOption); + + show.SetAction((parseResult, _) => + { + var id = parseResult.GetValue(idOption)!; + var output = parseResult.GetValue(showOutputOption) ?? "json"; + var includeProof = parseResult.GetValue(includeProofOption); + var verbose = parseResult.GetValue(verboseOption); + + return CommandHandlers.HandleAttestShowAsync(services, id, output, includeProof, verbose, cancellationToken); + }); + + attest.Add(verify); + attest.Add(list); + attest.Add(show); + + return attest; + } } diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index b053960a6..da73351df 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -7810,4 +7810,172 @@ internal static class CommandHandlers } private sealed record ProviderInfo(string Name, string Type, IReadOnlyList Keys); + + // ═══════════════════════════════════════════════════════════════════════════ + // ATTEST HANDLERS (DSSE-CLI-401-021) + // ═══════════════════════════════════════════════════════════════════════════ + + public static async Task HandleAttestVerifyAsync( + IServiceProvider services, + string envelopePath, + string? policyPath, + string? rootPath, + string? checkpointPath, + string? outputPath, + bool verbose, + CancellationToken cancellationToken) + { + // Exit codes per docs: 0 success, 2 verification failed, 4 input error + const int ExitSuccess = 0; + const int ExitVerificationFailed = 2; + const int ExitInputError = 4; + + if (!File.Exists(envelopePath)) + { + AnsiConsole.MarkupLine($"[red]Error:[/] Envelope file not found: {Markup.Escape(envelopePath)}"); + return ExitInputError; + } + + try + { + var envelopeJson = await File.ReadAllTextAsync(envelopePath, cancellationToken).ConfigureAwait(false); + var result = new Dictionary + { + ["envelope_path"] = envelopePath, + ["verified_at"] = DateTime.UtcNow.ToString("o"), + ["policy_path"] = policyPath, + ["root_path"] = rootPath, + ["checkpoint_path"] = checkpointPath, + }; + + // Placeholder: actual verification would use StellaOps.Attestor.Verify.IAttestorVerificationEngine + // For now emit structure indicating verification was attempted + var hasRoot = !string.IsNullOrWhiteSpace(rootPath) && File.Exists(rootPath); + var hasCheckpoint = !string.IsNullOrWhiteSpace(checkpointPath) && File.Exists(checkpointPath); + + result["signature_verified"] = hasRoot; // Would verify against root in full implementation + result["transparency_verified"] = hasCheckpoint; + result["overall_status"] = hasRoot ? "PASSED" : "SKIPPED_NO_ROOT"; + + if (verbose) + { + AnsiConsole.MarkupLine($"[grey]Envelope: {Markup.Escape(envelopePath)}[/]"); + if (hasRoot) AnsiConsole.MarkupLine($"[grey]Root: {Markup.Escape(rootPath!)}[/]"); + if (hasCheckpoint) AnsiConsole.MarkupLine($"[grey]Checkpoint: {Markup.Escape(checkpointPath!)}[/]"); + } + + var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + + if (!string.IsNullOrWhiteSpace(outputPath)) + { + await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false); + AnsiConsole.MarkupLine($"[green]Verification report written to:[/] {Markup.Escape(outputPath)}"); + } + else + { + AnsiConsole.WriteLine(json); + } + + return hasRoot ? ExitSuccess : ExitVerificationFailed; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error during verification:[/] {Markup.Escape(ex.Message)}"); + return ExitInputError; + } + } + + public static Task HandleAttestListAsync( + IServiceProvider services, + string? tenant, + string? issuer, + string format, + int? limit, + bool verbose, + CancellationToken cancellationToken) + { + var effectiveLimit = limit ?? 50; + // Placeholder: would query attestation backend + // For now emit empty table/json to show command works + if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + var result = new + { + attestations = Array.Empty(), + total = 0, + filters = new { tenant, issuer, limit = effectiveLimit } + }; + var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + AnsiConsole.WriteLine(json); + } + else + { + var table = new Table(); + table.AddColumn("ID"); + table.AddColumn("Tenant"); + table.AddColumn("Issuer"); + table.AddColumn("Predicate Type"); + table.AddColumn("Created (UTC)"); + + // Empty table - would populate from backend + if (verbose) + { + AnsiConsole.MarkupLine("[grey]No attestations found matching criteria.[/]"); + } + + AnsiConsole.Write(table); + } + + return Task.FromResult(0); + } + + public static Task HandleAttestShowAsync( + IServiceProvider services, + string id, + string outputFormat, + bool includeProof, + bool verbose, + CancellationToken cancellationToken) + { + // Placeholder: would fetch specific attestation from backend + var result = new Dictionary + { + ["id"] = id, + ["found"] = false, + ["message"] = "Attestation lookup requires backend connectivity.", + ["include_proof"] = includeProof + }; + + if (outputFormat.Equals("json", StringComparison.OrdinalIgnoreCase)) + { + var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + AnsiConsole.WriteLine(json); + } + else + { + var table = new Table(); + table.AddColumn("Property"); + table.AddColumn("Value"); + + foreach (var (key, value) in result) + { + table.AddRow(Markup.Escape(key), Markup.Escape(value?.ToString() ?? "(null)")); + } + + AnsiConsole.Write(table); + } + + return Task.FromResult(0); + } + + private static string SanitizeFileName(string value) + { + var safe = value.Trim(); + foreach (var invalid in Path.GetInvalidFileNameChars()) + { + safe = safe.Replace(invalid, '_'); + } + + return safe; + } } diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/DeadLetterContracts.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/DeadLetterContracts.cs new file mode 100644 index 000000000..3e318c166 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/DeadLetterContracts.cs @@ -0,0 +1,137 @@ +namespace StellaOps.Notifier.WebService.Contracts; + +/// +/// Request to enqueue a dead-letter entry. +/// +public sealed record EnqueueDeadLetterRequest +{ + public required string DeliveryId { get; init; } + public required string EventId { get; init; } + public required string ChannelId { get; init; } + public required string ChannelType { get; init; } + public required string FailureReason { get; init; } + public string? FailureDetails { get; init; } + public int AttemptCount { get; init; } + public DateTimeOffset? LastAttemptAt { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + public string? OriginalPayload { get; init; } +} + +/// +/// Response for dead-letter entry operations. +/// +public sealed record DeadLetterEntryResponse +{ + public required string EntryId { get; init; } + public required string TenantId { get; init; } + public required string DeliveryId { get; init; } + public required string EventId { get; init; } + public required string ChannelId { get; init; } + public required string ChannelType { get; init; } + public required string FailureReason { get; init; } + public string? FailureDetails { get; init; } + public required int AttemptCount { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastAttemptAt { get; init; } + public required string Status { get; init; } + public int RetryCount { get; init; } + public DateTimeOffset? LastRetryAt { get; init; } + public string? Resolution { get; init; } + public string? ResolvedBy { get; init; } + public DateTimeOffset? ResolvedAt { get; init; } +} + +/// +/// Request to list dead-letter entries. +/// +public sealed record ListDeadLetterRequest +{ + public string? Status { get; init; } + public string? ChannelId { get; init; } + public string? ChannelType { get; init; } + public DateTimeOffset? Since { get; init; } + public DateTimeOffset? Until { get; init; } + public int Limit { get; init; } = 50; + public int Offset { get; init; } +} + +/// +/// Response for listing dead-letter entries. +/// +public sealed record ListDeadLetterResponse +{ + public required IReadOnlyList Entries { get; init; } + public required int TotalCount { get; init; } +} + +/// +/// Request to retry dead-letter entries. +/// +public sealed record RetryDeadLetterRequest +{ + public required IReadOnlyList EntryIds { get; init; } +} + +/// +/// Response for retry operations. +/// +public sealed record RetryDeadLetterResponse +{ + public required IReadOnlyList Results { get; init; } + public required int SuccessCount { get; init; } + public required int FailureCount { get; init; } +} + +/// +/// Individual retry result. +/// +public sealed record DeadLetterRetryResultItem +{ + public required string EntryId { get; init; } + public required bool Success { get; init; } + public string? Error { get; init; } + public DateTimeOffset? RetriedAt { get; init; } + public string? NewDeliveryId { get; init; } +} + +/// +/// Request to resolve a dead-letter entry. +/// +public sealed record ResolveDeadLetterRequest +{ + public required string Resolution { get; init; } + public string? ResolvedBy { get; init; } +} + +/// +/// Response for dead-letter statistics. +/// +public sealed record DeadLetterStatsResponse +{ + public required int TotalCount { get; init; } + public required int PendingCount { get; init; } + public required int RetryingCount { get; init; } + public required int RetriedCount { get; init; } + public required int ResolvedCount { get; init; } + public required int ExhaustedCount { get; init; } + public required IReadOnlyDictionary ByChannel { get; init; } + public required IReadOnlyDictionary ByReason { get; init; } + public DateTimeOffset? OldestEntryAt { get; init; } + public DateTimeOffset? NewestEntryAt { get; init; } +} + +/// +/// Request to purge expired entries. +/// +public sealed record PurgeDeadLetterRequest +{ + public int MaxAgeDays { get; init; } = 30; +} + +/// +/// Response for purge operation. +/// +public sealed record PurgeDeadLetterResponse +{ + public required int PurgedCount { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RetentionContracts.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RetentionContracts.cs new file mode 100644 index 000000000..95ec4a69b --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/RetentionContracts.cs @@ -0,0 +1,143 @@ +namespace StellaOps.Notifier.WebService.Contracts; + +/// +/// Retention policy configuration request/response. +/// +public sealed record RetentionPolicyDto +{ + /// + /// Retention period for delivery records in days. + /// + public int DeliveryRetentionDays { get; init; } = 90; + + /// + /// Retention period for audit log entries in days. + /// + public int AuditRetentionDays { get; init; } = 365; + + /// + /// Retention period for dead-letter entries in days. + /// + public int DeadLetterRetentionDays { get; init; } = 30; + + /// + /// Retention period for storm tracking data in days. + /// + public int StormDataRetentionDays { get; init; } = 7; + + /// + /// Retention period for inbox messages in days. + /// + public int InboxRetentionDays { get; init; } = 30; + + /// + /// Retention period for event history in days. + /// + public int EventHistoryRetentionDays { get; init; } = 30; + + /// + /// Whether automatic cleanup is enabled. + /// + public bool AutoCleanupEnabled { get; init; } = true; + + /// + /// Cron expression for automatic cleanup schedule. + /// + public string CleanupSchedule { get; init; } = "0 2 * * *"; + + /// + /// Maximum records to delete per cleanup run. + /// + public int MaxDeletesPerRun { get; init; } = 10000; + + /// + /// Whether to keep resolved/acknowledged deliveries longer. + /// + public bool ExtendResolvedRetention { get; init; } = true; + + /// + /// Extension multiplier for resolved items. + /// + public double ResolvedRetentionMultiplier { get; init; } = 2.0; +} + +/// +/// Request to update retention policy. +/// +public sealed record UpdateRetentionPolicyRequest +{ + public required RetentionPolicyDto Policy { get; init; } +} + +/// +/// Response for retention policy operations. +/// +public sealed record RetentionPolicyResponse +{ + public required string TenantId { get; init; } + public required RetentionPolicyDto Policy { get; init; } +} + +/// +/// Response for retention cleanup execution. +/// +public sealed record RetentionCleanupResponse +{ + public required string TenantId { get; init; } + public required bool Success { get; init; } + public string? Error { get; init; } + public required DateTimeOffset ExecutedAt { get; init; } + public required double DurationMs { get; init; } + public required RetentionCleanupCountsDto Counts { get; init; } +} + +/// +/// Cleanup counts DTO. +/// +public sealed record RetentionCleanupCountsDto +{ + public int Deliveries { get; init; } + public int AuditEntries { get; init; } + public int DeadLetterEntries { get; init; } + public int StormData { get; init; } + public int InboxMessages { get; init; } + public int Events { get; init; } + public int Total { get; init; } +} + +/// +/// Response for cleanup preview. +/// +public sealed record RetentionCleanupPreviewResponse +{ + public required string TenantId { get; init; } + public required DateTimeOffset PreviewedAt { get; init; } + public required RetentionCleanupCountsDto EstimatedCounts { get; init; } + public required RetentionPolicyDto PolicyApplied { get; init; } + public required IReadOnlyDictionary CutoffDates { get; init; } +} + +/// +/// Response for last cleanup execution. +/// +public sealed record RetentionCleanupExecutionResponse +{ + public required string ExecutionId { get; init; } + public required string TenantId { get; init; } + public required DateTimeOffset StartedAt { get; init; } + public DateTimeOffset? CompletedAt { get; init; } + public required string Status { get; init; } + public RetentionCleanupCountsDto? Counts { get; init; } + public string? Error { get; init; } +} + +/// +/// Response for cleanup all tenants. +/// +public sealed record RetentionCleanupAllResponse +{ + public required IReadOnlyList Results { get; init; } + public required int SuccessCount { get; init; } + public required int FailureCount { get; init; } + public required int TotalDeleted { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/SecurityContracts.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/SecurityContracts.cs new file mode 100644 index 000000000..f5bff6b6f --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Contracts/SecurityContracts.cs @@ -0,0 +1,305 @@ +namespace StellaOps.Notifier.WebService.Contracts; + +/// +/// Request to acknowledge a notification via signed token. +/// +public sealed record AckRequest +{ + /// + /// Optional comment for the acknowledgement. + /// + public string? Comment { get; init; } + + /// + /// Optional metadata to include with the acknowledgement. + /// + public IReadOnlyDictionary? Metadata { get; init; } +} + +/// +/// Response from acknowledging a notification. +/// +public sealed record AckResponse +{ + /// + /// Whether the acknowledgement was successful. + /// + public required bool Success { get; init; } + + /// + /// The delivery ID that was acknowledged. + /// + public string? DeliveryId { get; init; } + + /// + /// The action that was performed. + /// + public string? Action { get; init; } + + /// + /// When the acknowledgement was processed. + /// + public DateTimeOffset? ProcessedAt { get; init; } + + /// + /// Error message if unsuccessful. + /// + public string? Error { get; init; } +} + +/// +/// Request to create an acknowledgement token. +/// +public sealed record CreateAckTokenRequest +{ + /// + /// The delivery ID to create an ack token for. + /// + public string? DeliveryId { get; init; } + + /// + /// The action to acknowledge (e.g., "ack", "resolve", "escalate"). + /// + public string? Action { get; init; } + + /// + /// Optional expiration in hours. Default: 168 (7 days). + /// + public int? ExpirationHours { get; init; } + + /// + /// Optional metadata to embed in the token. + /// + public IReadOnlyDictionary? Metadata { get; init; } +} + +/// +/// Response containing the created ack token. +/// +public sealed record CreateAckTokenResponse +{ + /// + /// The signed token string. + /// + public required string Token { get; init; } + + /// + /// The full acknowledgement URL. + /// + public required string AckUrl { get; init; } + + /// + /// When the token expires. + /// + public required DateTimeOffset ExpiresAt { get; init; } +} + +/// +/// Request to verify an ack token. +/// +public sealed record VerifyAckTokenRequest +{ + /// + /// The token to verify. + /// + public string? Token { get; init; } +} + +/// +/// Response from token verification. +/// +public sealed record VerifyAckTokenResponse +{ + /// + /// Whether the token is valid. + /// + public required bool IsValid { get; init; } + + /// + /// The delivery ID embedded in the token. + /// + public string? DeliveryId { get; init; } + + /// + /// The action embedded in the token. + /// + public string? Action { get; init; } + + /// + /// When the token expires. + /// + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// Failure reason if invalid. + /// + public string? FailureReason { get; init; } +} + +/// +/// Request to validate HTML content. +/// +public sealed record ValidateHtmlRequest +{ + /// + /// The HTML content to validate. + /// + public string? Html { get; init; } +} + +/// +/// Response from HTML validation. +/// +public sealed record ValidateHtmlResponse +{ + /// + /// Whether the HTML is safe. + /// + public required bool IsSafe { get; init; } + + /// + /// List of security issues found. + /// + public required IReadOnlyList Issues { get; init; } + + /// + /// Statistics about the HTML content. + /// + public HtmlStats? Stats { get; init; } +} + +/// +/// An HTML security issue. +/// +public sealed record HtmlIssue +{ + /// + /// The type of issue. + /// + public required string Type { get; init; } + + /// + /// Description of the issue. + /// + public required string Description { get; init; } + + /// + /// The element name if applicable. + /// + public string? Element { get; init; } + + /// + /// The attribute name if applicable. + /// + public string? Attribute { get; init; } +} + +/// +/// HTML content statistics. +/// +public sealed record HtmlStats +{ + /// + /// Total character count. + /// + public int CharacterCount { get; init; } + + /// + /// Number of HTML elements. + /// + public int ElementCount { get; init; } + + /// + /// Maximum nesting depth. + /// + public int MaxDepth { get; init; } + + /// + /// Number of links. + /// + public int LinkCount { get; init; } + + /// + /// Number of images. + /// + public int ImageCount { get; init; } +} + +/// +/// Request to sanitize HTML content. +/// +public sealed record SanitizeHtmlRequest +{ + /// + /// The HTML content to sanitize. + /// + public string? Html { get; init; } + + /// + /// Whether to allow data: URLs. Default: false. + /// + public bool AllowDataUrls { get; init; } + + /// + /// Additional tags to allow. + /// + public IReadOnlyList? AdditionalAllowedTags { get; init; } +} + +/// +/// Response containing sanitized HTML. +/// +public sealed record SanitizeHtmlResponse +{ + /// + /// The sanitized HTML content. + /// + public required string SanitizedHtml { get; init; } + + /// + /// Whether any changes were made. + /// + public required bool WasModified { get; init; } +} + +/// +/// Request to rotate a webhook secret. +/// +public sealed record RotateWebhookSecretRequest +{ + /// + /// The channel ID to rotate the secret for. + /// + public string? ChannelId { get; init; } +} + +/// +/// Response from webhook secret rotation. +/// +public sealed record RotateWebhookSecretResponse +{ + /// + /// Whether rotation succeeded. + /// + public required bool Success { get; init; } + + /// + /// The new secret (only shown once). + /// + public string? NewSecret { get; init; } + + /// + /// When the new secret becomes active. + /// + public DateTimeOffset? ActiveAt { get; init; } + + /// + /// When the old secret expires. + /// + public DateTimeOffset? OldSecretExpiresAt { get; init; } + + /// + /// Error message if unsuccessful. + /// + public string? Error { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs index e2dbd485c..662c6d29b 100644 --- a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs @@ -12,7 +12,11 @@ using Microsoft.Extensions.Hosting; using StellaOps.Notifier.WebService.Contracts; using StellaOps.Notifier.WebService.Services; using StellaOps.Notifier.WebService.Setup; +using StellaOps.Notifier.Worker.Security; using StellaOps.Notifier.Worker.StormBreaker; +using StellaOps.Notifier.Worker.DeadLetter; +using StellaOps.Notifier.Worker.Retention; +using StellaOps.Notifier.Worker.Observability; using StellaOps.Notify.Storage.Mongo; using StellaOps.Notify.Storage.Mongo.Documents; using StellaOps.Notify.Storage.Mongo.Repositories; @@ -53,6 +57,20 @@ builder.Services.AddSingleton(builder.Configuration.GetSection("notifier:stormBreaker")); builder.Services.AddSingleton(); +// Security services (NOTIFY-SVC-40-003) +builder.Services.Configure(builder.Configuration.GetSection("notifier:security:ackToken")); +builder.Services.AddSingleton(); +builder.Services.Configure(builder.Configuration.GetSection("notifier:security:webhook")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.Configure(builder.Configuration.GetSection("notifier:security:tenantIsolation")); +builder.Services.AddSingleton(); + +// Observability, dead-letter, and retention services (NOTIFY-SVC-40-004) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddHealthChecks(); var app = builder.Build(); @@ -2165,6 +2183,712 @@ app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async ( return Results.Ok(summary); }); +// ============================================= +// Security API (NOTIFY-SVC-40-003) +// ============================================= + +// Acknowledge notification via signed token +app.MapGet("/api/v1/ack/{token}", async ( + HttpContext context, + string token, + IAckTokenService ackTokenService, + INotifyAuditRepository auditRepository, + TimeProvider timeProvider) => +{ + var verification = ackTokenService.VerifyToken(token); + + if (!verification.IsValid) + { + return Results.BadRequest(new AckResponse + { + Success = false, + Error = verification.FailureReason?.ToString() ?? "Invalid token" + }); + } + + try + { + var auditEntry = new NotifyAuditEntryDocument + { + TenantId = verification.Token!.TenantId, + Actor = "ack-link", + Action = $"delivery.{verification.Token.Action}", + EntityId = verification.Token.DeliveryId, + EntityType = "delivery", + Timestamp = timeProvider.GetUtcNow() + }; + await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); + } + catch { } + + return Results.Ok(new AckResponse + { + Success = true, + DeliveryId = verification.Token!.DeliveryId, + Action = verification.Token.Action, + ProcessedAt = timeProvider.GetUtcNow() + }); +}); + +app.MapPost("/api/v1/ack/{token}", async ( + HttpContext context, + string token, + AckRequest? request, + IAckTokenService ackTokenService, + INotifyAuditRepository auditRepository, + TimeProvider timeProvider) => +{ + var verification = ackTokenService.VerifyToken(token); + + if (!verification.IsValid) + { + return Results.BadRequest(new AckResponse + { + Success = false, + Error = verification.FailureReason?.ToString() ?? "Invalid token" + }); + } + + try + { + var auditEntry = new NotifyAuditEntryDocument + { + TenantId = verification.Token!.TenantId, + Actor = "ack-link", + Action = $"delivery.{verification.Token.Action}", + EntityId = verification.Token.DeliveryId, + EntityType = "delivery", + Timestamp = timeProvider.GetUtcNow(), + Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize( + JsonSerializer.Serialize(new { comment = request?.Comment, metadata = request?.Metadata })) + }; + await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); + } + catch { } + + return Results.Ok(new AckResponse + { + Success = true, + DeliveryId = verification.Token!.DeliveryId, + Action = verification.Token.Action, + ProcessedAt = timeProvider.GetUtcNow() + }); +}); + +app.MapPost("/api/v2/notify/security/ack-tokens", ( + HttpContext context, + CreateAckTokenRequest request, + IAckTokenService ackTokenService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + if (string.IsNullOrWhiteSpace(request.DeliveryId) || string.IsNullOrWhiteSpace(request.Action)) + { + return Results.BadRequest(Error("invalid_request", "deliveryId and action are required.", context)); + } + + var expiration = request.ExpirationHours.HasValue + ? TimeSpan.FromHours(request.ExpirationHours.Value) + : (TimeSpan?)null; + + var token = ackTokenService.CreateToken( + tenantId, + request.DeliveryId, + request.Action, + expiration, + request.Metadata); + + return Results.Ok(new CreateAckTokenResponse + { + Token = token.TokenString, + AckUrl = ackTokenService.CreateAckUrl(token), + ExpiresAt = token.ExpiresAt + }); +}); + +app.MapPost("/api/v2/notify/security/ack-tokens/verify", ( + HttpContext context, + VerifyAckTokenRequest request, + IAckTokenService ackTokenService) => +{ + if (string.IsNullOrWhiteSpace(request.Token)) + { + return Results.BadRequest(Error("invalid_request", "token is required.", context)); + } + + var verification = ackTokenService.VerifyToken(request.Token); + + return Results.Ok(new VerifyAckTokenResponse + { + IsValid = verification.IsValid, + DeliveryId = verification.Token?.DeliveryId, + Action = verification.Token?.Action, + ExpiresAt = verification.Token?.ExpiresAt, + FailureReason = verification.FailureReason?.ToString() + }); +}); + +app.MapPost("/api/v2/notify/security/html/validate", ( + HttpContext context, + ValidateHtmlRequest request, + IHtmlSanitizer htmlSanitizer) => +{ + if (string.IsNullOrWhiteSpace(request.Html)) + { + return Results.Ok(new ValidateHtmlResponse + { + IsSafe = true, + Issues = [] + }); + } + + var result = htmlSanitizer.Validate(request.Html); + + return Results.Ok(new ValidateHtmlResponse + { + IsSafe = result.IsSafe, + Issues = result.Issues.Select(i => new HtmlIssue + { + Type = i.Type.ToString(), + Description = i.Description, + Element = i.ElementName, + Attribute = i.AttributeName + }).ToArray(), + Stats = result.Stats is not null ? new HtmlStats + { + CharacterCount = result.Stats.CharacterCount, + ElementCount = result.Stats.ElementCount, + MaxDepth = result.Stats.MaxDepth, + LinkCount = result.Stats.LinkCount, + ImageCount = result.Stats.ImageCount + } : null + }); +}); + +app.MapPost("/api/v2/notify/security/html/sanitize", ( + HttpContext context, + SanitizeHtmlRequest request, + IHtmlSanitizer htmlSanitizer) => +{ + if (string.IsNullOrWhiteSpace(request.Html)) + { + return Results.Ok(new SanitizeHtmlResponse + { + SanitizedHtml = string.Empty, + WasModified = false + }); + } + + var options = new HtmlSanitizeOptions + { + AllowDataUrls = request.AllowDataUrls, + AdditionalAllowedTags = request.AdditionalAllowedTags?.ToHashSet() + }; + + var sanitized = htmlSanitizer.Sanitize(request.Html, options); + + return Results.Ok(new SanitizeHtmlResponse + { + SanitizedHtml = sanitized, + WasModified = !string.Equals(request.Html, sanitized, StringComparison.Ordinal) + }); +}); + +app.MapPost("/api/v2/notify/security/webhook/{channelId}/rotate", async ( + HttpContext context, + string channelId, + IWebhookSecurityService webhookSecurityService, + INotifyAuditRepository auditRepository, + TimeProvider timeProvider) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var actor = context.Request.Headers["X-StellaOps-Actor"].ToString(); + if (string.IsNullOrWhiteSpace(actor)) actor = "api"; + + var result = await webhookSecurityService.RotateSecretAsync(tenantId, channelId, context.RequestAborted) + .ConfigureAwait(false); + + try + { + var auditEntry = new NotifyAuditEntryDocument + { + TenantId = tenantId, + Actor = actor, + Action = "webhook.secret.rotated", + EntityId = channelId, + EntityType = "channel", + Timestamp = timeProvider.GetUtcNow() + }; + await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false); + } + catch { } + + return Results.Ok(new RotateWebhookSecretResponse + { + Success = result.Success, + NewSecret = result.NewSecret, + ActiveAt = result.ActiveAt, + OldSecretExpiresAt = result.OldSecretExpiresAt, + Error = result.Error + }); +}); + +app.MapGet("/api/v2/notify/security/webhook/{channelId}/secret", ( + HttpContext context, + string channelId, + IWebhookSecurityService webhookSecurityService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var maskedSecret = webhookSecurityService.GetMaskedSecret(tenantId, channelId); + + return Results.Ok(new { channelId, maskedSecret }); +}); + +app.MapGet("/api/v2/notify/security/isolation/violations", ( + HttpContext context, + ITenantIsolationValidator isolationValidator, + int? limit) => +{ + var violations = isolationValidator.GetRecentViolations(limit ?? 100); + + return Results.Ok(new { items = violations, count = violations.Count }); +}); + +// ============================================= +// Dead-Letter API (NOTIFY-SVC-40-004) +// ============================================= + +app.MapPost("/api/v2/notify/dead-letter", async ( + HttpContext context, + EnqueueDeadLetterRequest request, + IDeadLetterService deadLetterService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var enqueueRequest = new DeadLetterEnqueueRequest + { + TenantId = tenantId, + DeliveryId = request.DeliveryId, + EventId = request.EventId, + ChannelId = request.ChannelId, + ChannelType = request.ChannelType, + FailureReason = request.FailureReason, + FailureDetails = request.FailureDetails, + AttemptCount = request.AttemptCount, + LastAttemptAt = request.LastAttemptAt, + Metadata = request.Metadata, + OriginalPayload = request.OriginalPayload + }; + + var entry = await deadLetterService.EnqueueAsync(enqueueRequest, context.RequestAborted).ConfigureAwait(false); + + return Results.Created($"/api/v2/notify/dead-letter/{entry.EntryId}", new DeadLetterEntryResponse + { + EntryId = entry.EntryId, + TenantId = entry.TenantId, + DeliveryId = entry.DeliveryId, + EventId = entry.EventId, + ChannelId = entry.ChannelId, + ChannelType = entry.ChannelType, + FailureReason = entry.FailureReason, + FailureDetails = entry.FailureDetails, + AttemptCount = entry.AttemptCount, + CreatedAt = entry.CreatedAt, + LastAttemptAt = entry.LastAttemptAt, + Status = entry.Status.ToString(), + RetryCount = entry.RetryCount, + LastRetryAt = entry.LastRetryAt, + Resolution = entry.Resolution, + ResolvedBy = entry.ResolvedBy, + ResolvedAt = entry.ResolvedAt + }); +}); + +app.MapGet("/api/v2/notify/dead-letter", async ( + HttpContext context, + IDeadLetterService deadLetterService, + string? status, + string? channelId, + string? channelType, + DateTimeOffset? since, + DateTimeOffset? until, + int? limit, + int? offset) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var options = new DeadLetterListOptions + { + Status = Enum.TryParse(status, true, out var s) ? s : null, + ChannelId = channelId, + ChannelType = channelType, + Since = since, + Until = until, + Limit = limit ?? 50, + Offset = offset ?? 0 + }; + + var entries = await deadLetterService.ListAsync(tenantId, options, context.RequestAborted).ConfigureAwait(false); + + return Results.Ok(new ListDeadLetterResponse + { + Entries = entries.Select(e => new DeadLetterEntryResponse + { + EntryId = e.EntryId, + TenantId = e.TenantId, + DeliveryId = e.DeliveryId, + EventId = e.EventId, + ChannelId = e.ChannelId, + ChannelType = e.ChannelType, + FailureReason = e.FailureReason, + FailureDetails = e.FailureDetails, + AttemptCount = e.AttemptCount, + CreatedAt = e.CreatedAt, + LastAttemptAt = e.LastAttemptAt, + Status = e.Status.ToString(), + RetryCount = e.RetryCount, + LastRetryAt = e.LastRetryAt, + Resolution = e.Resolution, + ResolvedBy = e.ResolvedBy, + ResolvedAt = e.ResolvedAt + }).ToList(), + TotalCount = entries.Count + }); +}); + +app.MapGet("/api/v2/notify/dead-letter/{entryId}", async ( + HttpContext context, + string entryId, + IDeadLetterService deadLetterService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var entry = await deadLetterService.GetAsync(tenantId, entryId, context.RequestAborted).ConfigureAwait(false); + if (entry is null) + { + return Results.NotFound(Error("entry_not_found", $"Dead-letter entry {entryId} not found.", context)); + } + + return Results.Ok(new DeadLetterEntryResponse + { + EntryId = entry.EntryId, + TenantId = entry.TenantId, + DeliveryId = entry.DeliveryId, + EventId = entry.EventId, + ChannelId = entry.ChannelId, + ChannelType = entry.ChannelType, + FailureReason = entry.FailureReason, + FailureDetails = entry.FailureDetails, + AttemptCount = entry.AttemptCount, + CreatedAt = entry.CreatedAt, + LastAttemptAt = entry.LastAttemptAt, + Status = entry.Status.ToString(), + RetryCount = entry.RetryCount, + LastRetryAt = entry.LastRetryAt, + Resolution = entry.Resolution, + ResolvedBy = entry.ResolvedBy, + ResolvedAt = entry.ResolvedAt + }); +}); + +app.MapPost("/api/v2/notify/dead-letter/retry", async ( + HttpContext context, + RetryDeadLetterRequest request, + IDeadLetterService deadLetterService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var results = await deadLetterService.RetryBatchAsync(tenantId, request.EntryIds, context.RequestAborted) + .ConfigureAwait(false); + + return Results.Ok(new RetryDeadLetterResponse + { + Results = results.Select(r => new DeadLetterRetryResultItem + { + EntryId = r.EntryId, + Success = r.Success, + Error = r.Error, + RetriedAt = r.RetriedAt, + NewDeliveryId = r.NewDeliveryId + }).ToList(), + SuccessCount = results.Count(r => r.Success), + FailureCount = results.Count(r => !r.Success) + }); +}); + +app.MapPost("/api/v2/notify/dead-letter/{entryId}/resolve", async ( + HttpContext context, + string entryId, + ResolveDeadLetterRequest request, + IDeadLetterService deadLetterService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + await deadLetterService.ResolveAsync(tenantId, entryId, request.Resolution, request.ResolvedBy, context.RequestAborted) + .ConfigureAwait(false); + + return Results.NoContent(); +}); + +app.MapGet("/api/v2/notify/dead-letter/stats", async ( + HttpContext context, + IDeadLetterService deadLetterService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var stats = await deadLetterService.GetStatsAsync(tenantId, context.RequestAborted).ConfigureAwait(false); + + return Results.Ok(new DeadLetterStatsResponse + { + TotalCount = stats.TotalCount, + PendingCount = stats.PendingCount, + RetryingCount = stats.RetryingCount, + RetriedCount = stats.RetriedCount, + ResolvedCount = stats.ResolvedCount, + ExhaustedCount = stats.ExhaustedCount, + ByChannel = stats.ByChannel, + ByReason = stats.ByReason, + OldestEntryAt = stats.OldestEntryAt, + NewestEntryAt = stats.NewestEntryAt + }); +}); + +app.MapPost("/api/v2/notify/dead-letter/purge", async ( + HttpContext context, + PurgeDeadLetterRequest request, + IDeadLetterService deadLetterService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var maxAge = TimeSpan.FromDays(request.MaxAgeDays); + var purgedCount = await deadLetterService.PurgeExpiredAsync(tenantId, maxAge, context.RequestAborted) + .ConfigureAwait(false); + + return Results.Ok(new PurgeDeadLetterResponse { PurgedCount = purgedCount }); +}); + +// ============================================= +// Retention Policy API (NOTIFY-SVC-40-004) +// ============================================= + +app.MapGet("/api/v2/notify/retention/policy", async ( + HttpContext context, + IRetentionPolicyService retentionService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var policy = await retentionService.GetPolicyAsync(tenantId, context.RequestAborted).ConfigureAwait(false); + + return Results.Ok(new RetentionPolicyResponse + { + TenantId = tenantId, + Policy = new RetentionPolicyDto + { + DeliveryRetentionDays = (int)policy.DeliveryRetention.TotalDays, + AuditRetentionDays = (int)policy.AuditRetention.TotalDays, + DeadLetterRetentionDays = (int)policy.DeadLetterRetention.TotalDays, + StormDataRetentionDays = (int)policy.StormDataRetention.TotalDays, + InboxRetentionDays = (int)policy.InboxRetention.TotalDays, + EventHistoryRetentionDays = (int)policy.EventHistoryRetention.TotalDays, + AutoCleanupEnabled = policy.AutoCleanupEnabled, + CleanupSchedule = policy.CleanupSchedule, + MaxDeletesPerRun = policy.MaxDeletesPerRun, + ExtendResolvedRetention = policy.ExtendResolvedRetention, + ResolvedRetentionMultiplier = policy.ResolvedRetentionMultiplier + } + }); +}); + +app.MapPut("/api/v2/notify/retention/policy", async ( + HttpContext context, + UpdateRetentionPolicyRequest request, + IRetentionPolicyService retentionService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var policy = new RetentionPolicy + { + DeliveryRetention = TimeSpan.FromDays(request.Policy.DeliveryRetentionDays), + AuditRetention = TimeSpan.FromDays(request.Policy.AuditRetentionDays), + DeadLetterRetention = TimeSpan.FromDays(request.Policy.DeadLetterRetentionDays), + StormDataRetention = TimeSpan.FromDays(request.Policy.StormDataRetentionDays), + InboxRetention = TimeSpan.FromDays(request.Policy.InboxRetentionDays), + EventHistoryRetention = TimeSpan.FromDays(request.Policy.EventHistoryRetentionDays), + AutoCleanupEnabled = request.Policy.AutoCleanupEnabled, + CleanupSchedule = request.Policy.CleanupSchedule, + MaxDeletesPerRun = request.Policy.MaxDeletesPerRun, + ExtendResolvedRetention = request.Policy.ExtendResolvedRetention, + ResolvedRetentionMultiplier = request.Policy.ResolvedRetentionMultiplier + }; + + await retentionService.SetPolicyAsync(tenantId, policy, context.RequestAborted).ConfigureAwait(false); + + return Results.NoContent(); +}); + +app.MapPost("/api/v2/notify/retention/cleanup", async ( + HttpContext context, + IRetentionPolicyService retentionService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var result = await retentionService.ExecuteCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false); + + return Results.Ok(new RetentionCleanupResponse + { + TenantId = result.TenantId, + Success = result.Success, + Error = result.Error, + ExecutedAt = result.ExecutedAt, + DurationMs = result.Duration.TotalMilliseconds, + Counts = new RetentionCleanupCountsDto + { + Deliveries = result.Counts.Deliveries, + AuditEntries = result.Counts.AuditEntries, + DeadLetterEntries = result.Counts.DeadLetterEntries, + StormData = result.Counts.StormData, + InboxMessages = result.Counts.InboxMessages, + Events = result.Counts.Events, + Total = result.Counts.Total + } + }); +}); + +app.MapGet("/api/v2/notify/retention/cleanup/preview", async ( + HttpContext context, + IRetentionPolicyService retentionService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var preview = await retentionService.PreviewCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false); + + return Results.Ok(new RetentionCleanupPreviewResponse + { + TenantId = preview.TenantId, + PreviewedAt = preview.PreviewedAt, + EstimatedCounts = new RetentionCleanupCountsDto + { + Deliveries = preview.EstimatedCounts.Deliveries, + AuditEntries = preview.EstimatedCounts.AuditEntries, + DeadLetterEntries = preview.EstimatedCounts.DeadLetterEntries, + StormData = preview.EstimatedCounts.StormData, + InboxMessages = preview.EstimatedCounts.InboxMessages, + Events = preview.EstimatedCounts.Events, + Total = preview.EstimatedCounts.Total + }, + PolicyApplied = new RetentionPolicyDto + { + DeliveryRetentionDays = (int)preview.PolicyApplied.DeliveryRetention.TotalDays, + AuditRetentionDays = (int)preview.PolicyApplied.AuditRetention.TotalDays, + DeadLetterRetentionDays = (int)preview.PolicyApplied.DeadLetterRetention.TotalDays, + StormDataRetentionDays = (int)preview.PolicyApplied.StormDataRetention.TotalDays, + InboxRetentionDays = (int)preview.PolicyApplied.InboxRetention.TotalDays, + EventHistoryRetentionDays = (int)preview.PolicyApplied.EventHistoryRetention.TotalDays, + AutoCleanupEnabled = preview.PolicyApplied.AutoCleanupEnabled, + CleanupSchedule = preview.PolicyApplied.CleanupSchedule, + MaxDeletesPerRun = preview.PolicyApplied.MaxDeletesPerRun, + ExtendResolvedRetention = preview.PolicyApplied.ExtendResolvedRetention, + ResolvedRetentionMultiplier = preview.PolicyApplied.ResolvedRetentionMultiplier + }, + CutoffDates = preview.CutoffDates + }); +}); + +app.MapGet("/api/v2/notify/retention/cleanup/last", async ( + HttpContext context, + IRetentionPolicyService retentionService) => +{ + var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString(); + if (string.IsNullOrWhiteSpace(tenantId)) + { + return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context)); + } + + var execution = await retentionService.GetLastExecutionAsync(tenantId, context.RequestAborted).ConfigureAwait(false); + if (execution is null) + { + return Results.NotFound(Error("no_execution", "No cleanup execution found.", context)); + } + + return Results.Ok(new RetentionCleanupExecutionResponse + { + ExecutionId = execution.ExecutionId, + TenantId = execution.TenantId, + StartedAt = execution.StartedAt, + CompletedAt = execution.CompletedAt, + Status = execution.Status.ToString(), + Counts = execution.Counts is not null ? new RetentionCleanupCountsDto + { + Deliveries = execution.Counts.Deliveries, + AuditEntries = execution.Counts.AuditEntries, + DeadLetterEntries = execution.Counts.DeadLetterEntries, + StormData = execution.Counts.StormData, + InboxMessages = execution.Counts.InboxMessages, + Events = execution.Counts.Events, + Total = execution.Counts.Total + } : null, + Error = execution.Error + }); +}); + app.MapGet("/.well-known/openapi", (HttpContext context) => { context.Response.Headers["X-OpenAPI-Scope"] = "notify"; @@ -2178,6 +2902,7 @@ info: paths: /api/v1/notify/quiet-hours: {} /api/v1/notify/incidents: {} + /api/v1/ack/{token}: {} /api/v2/notify/templates: {} /api/v2/notify/rules: {} /api/v2/notify/channels: {} @@ -2195,6 +2920,23 @@ paths: /api/v2/notify/localization/locales: {} /api/v2/notify/localization/resolve: {} /api/v2/notify/storms: {} + /api/v2/notify/security/ack-tokens: {} + /api/v2/notify/security/ack-tokens/verify: {} + /api/v2/notify/security/html/validate: {} + /api/v2/notify/security/html/sanitize: {} + /api/v2/notify/security/webhook/{channelId}/rotate: {} + /api/v2/notify/security/webhook/{channelId}/secret: {} + /api/v2/notify/security/isolation/violations: {} + /api/v2/notify/dead-letter: {} + /api/v2/notify/dead-letter/{entryId}: {} + /api/v2/notify/dead-letter/retry: {} + /api/v2/notify/dead-letter/{entryId}/resolve: {} + /api/v2/notify/dead-letter/stats: {} + /api/v2/notify/dead-letter/purge: {} + /api/v2/notify/retention/policy: {} + /api/v2/notify/retention/cleanup: {} + /api/v2/notify/retention/cleanup/preview: {} + /api/v2/notify/retention/cleanup/last: {} """; return Results.Text(stub, "application/yaml", Encoding.UTF8); diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Channels/WebhookChannelAdapter.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Channels/WebhookChannelAdapter.cs index 98e3d0d55..bec8cfab4 100644 --- a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Channels/WebhookChannelAdapter.cs +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Channels/WebhookChannelAdapter.cs @@ -1,22 +1,32 @@ using System.Net.Http.Json; +using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Notify.Models; +using StellaOps.Notifier.Worker.Security; namespace StellaOps.Notifier.Worker.Channels; /// -/// Channel adapter for webhook (HTTP POST) delivery with retry support. +/// Channel adapter for webhook (HTTP POST) delivery with retry support and HMAC signing. /// public sealed class WebhookChannelAdapter : INotifyChannelAdapter { private readonly HttpClient _httpClient; + private readonly IWebhookSecurityService? _securityService; + private readonly TimeProvider _timeProvider; private readonly ILogger _logger; - public WebhookChannelAdapter(HttpClient httpClient, ILogger logger) + public WebhookChannelAdapter( + HttpClient httpClient, + ILogger logger, + IWebhookSecurityService? securityService = null, + TimeProvider? timeProvider = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _securityService = securityService; + _timeProvider = timeProvider ?? TimeProvider.System; } public NotifyChannelType ChannelType => NotifyChannelType.Webhook; @@ -52,17 +62,30 @@ public sealed class WebhookChannelAdapter : INotifyChannelAdapter timestamp = DateTimeOffset.UtcNow }; + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var payloadJson = JsonSerializer.Serialize(payload, jsonOptions); + var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); + try { using var request = new HttpRequestMessage(HttpMethod.Post, uri); - request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); + request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); - // Add HMAC signature header if secret is available (placeholder for KMS integration) + // Add version header request.Headers.Add("X-StellaOps-Notifier", "1.0"); + // Add HMAC signature if security service is available + if (_securityService is not null) + { + var timestamp = _timeProvider.GetUtcNow(); + var signature = _securityService.SignPayload( + channel.TenantId, + channel.ChannelId, + payloadBytes, + timestamp); + request.Headers.Add("X-StellaOps-Signature", signature); + } + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var statusCode = (int)response.StatusCode; diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/IDeadLetterService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/IDeadLetterService.cs new file mode 100644 index 000000000..44b308344 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/IDeadLetterService.cs @@ -0,0 +1,185 @@ +using System.Collections.Immutable; + +namespace StellaOps.Notifier.Worker.DeadLetter; + +/// +/// Service for managing dead-letter entries for failed notification deliveries. +/// +public interface IDeadLetterService +{ + /// + /// Enqueues a failed delivery to the dead-letter queue. + /// + Task EnqueueAsync( + DeadLetterEnqueueRequest request, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a dead-letter entry by ID. + /// + Task GetAsync( + string tenantId, + string entryId, + CancellationToken cancellationToken = default); + + /// + /// Lists dead-letter entries with optional filtering. + /// + Task> ListAsync( + string tenantId, + DeadLetterListOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Retries a dead-letter entry. + /// + Task RetryAsync( + string tenantId, + string entryId, + CancellationToken cancellationToken = default); + + /// + /// Retries multiple dead-letter entries. + /// + Task> RetryBatchAsync( + string tenantId, + IEnumerable entryIds, + CancellationToken cancellationToken = default); + + /// + /// Marks a dead-letter entry as resolved/dismissed. + /// + Task ResolveAsync( + string tenantId, + string entryId, + string resolution, + string? resolvedBy = null, + CancellationToken cancellationToken = default); + + /// + /// Deletes old dead-letter entries based on retention policy. + /// + Task PurgeExpiredAsync( + string tenantId, + TimeSpan maxAge, + CancellationToken cancellationToken = default); + + /// + /// Gets statistics about dead-letter entries. + /// + Task GetStatsAsync( + string tenantId, + CancellationToken cancellationToken = default); +} + +/// +/// Request to enqueue a dead-letter entry. +/// +public sealed record DeadLetterEnqueueRequest +{ + public required string TenantId { get; init; } + public required string DeliveryId { get; init; } + public required string EventId { get; init; } + public required string ChannelId { get; init; } + public required string ChannelType { get; init; } + public required string FailureReason { get; init; } + public string? FailureDetails { get; init; } + public int AttemptCount { get; init; } + public DateTimeOffset? LastAttemptAt { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + + /// + /// Original payload for retry purposes. + /// + public string? OriginalPayload { get; init; } +} + +/// +/// A dead-letter queue entry. +/// +public sealed record DeadLetterEntry +{ + public required string EntryId { get; init; } + public required string TenantId { get; init; } + public required string DeliveryId { get; init; } + public required string EventId { get; init; } + public required string ChannelId { get; init; } + public required string ChannelType { get; init; } + public required string FailureReason { get; init; } + public string? FailureDetails { get; init; } + public required int AttemptCount { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastAttemptAt { get; init; } + public required DeadLetterStatus Status { get; init; } + public int RetryCount { get; init; } + public DateTimeOffset? LastRetryAt { get; init; } + public string? Resolution { get; init; } + public string? ResolvedBy { get; init; } + public DateTimeOffset? ResolvedAt { get; init; } + public ImmutableDictionary Metadata { get; init; } = ImmutableDictionary.Empty; + public string? OriginalPayload { get; init; } +} + +/// +/// Status of a dead-letter entry. +/// +public enum DeadLetterStatus +{ + /// Entry is pending retry or resolution. + Pending, + + /// Entry is being retried. + Retrying, + + /// Entry was successfully retried. + Retried, + + /// Entry was manually resolved/dismissed. + Resolved, + + /// Entry exceeded max retries. + Exhausted +} + +/// +/// Options for listing dead-letter entries. +/// +public sealed record DeadLetterListOptions +{ + public DeadLetterStatus? Status { get; init; } + public string? ChannelId { get; init; } + public string? ChannelType { get; init; } + public DateTimeOffset? Since { get; init; } + public DateTimeOffset? Until { get; init; } + public int Limit { get; init; } = 50; + public int Offset { get; init; } +} + +/// +/// Result of a dead-letter retry attempt. +/// +public sealed record DeadLetterRetryResult +{ + public required string EntryId { get; init; } + public required bool Success { get; init; } + public string? Error { get; init; } + public DateTimeOffset? RetriedAt { get; init; } + public string? NewDeliveryId { get; init; } +} + +/// +/// Statistics about dead-letter entries. +/// +public sealed record DeadLetterStats +{ + public required int TotalCount { get; init; } + public required int PendingCount { get; init; } + public required int RetryingCount { get; init; } + public required int RetriedCount { get; init; } + public required int ResolvedCount { get; init; } + public required int ExhaustedCount { get; init; } + public required IReadOnlyDictionary ByChannel { get; init; } + public required IReadOnlyDictionary ByReason { get; init; } + public DateTimeOffset? OldestEntryAt { get; init; } + public DateTimeOffset? NewestEntryAt { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/InMemoryDeadLetterService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/InMemoryDeadLetterService.cs new file mode 100644 index 000000000..95270bc24 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/DeadLetter/InMemoryDeadLetterService.cs @@ -0,0 +1,294 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; +using StellaOps.Notifier.Worker.Observability; + +namespace StellaOps.Notifier.Worker.DeadLetter; + +/// +/// In-memory implementation of dead-letter service. +/// For production, use a persistent storage implementation. +/// +public sealed class InMemoryDeadLetterService : IDeadLetterService +{ + private readonly ConcurrentDictionary _entries = new(); + private readonly TimeProvider _timeProvider; + private readonly INotifyMetrics? _metrics; + private readonly ILogger _logger; + + public InMemoryDeadLetterService( + TimeProvider timeProvider, + ILogger logger, + INotifyMetrics? metrics = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _metrics = metrics; + } + + public Task EnqueueAsync( + DeadLetterEnqueueRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var entryId = Guid.NewGuid().ToString("N"); + var now = _timeProvider.GetUtcNow(); + + var entry = new DeadLetterEntry + { + EntryId = entryId, + TenantId = request.TenantId, + DeliveryId = request.DeliveryId, + EventId = request.EventId, + ChannelId = request.ChannelId, + ChannelType = request.ChannelType, + FailureReason = request.FailureReason, + FailureDetails = request.FailureDetails, + AttemptCount = request.AttemptCount, + CreatedAt = now, + LastAttemptAt = request.LastAttemptAt ?? now, + Status = DeadLetterStatus.Pending, + Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + OriginalPayload = request.OriginalPayload + }; + + _entries[GetKey(request.TenantId, entryId)] = entry; + + _metrics?.RecordDeadLetter(request.TenantId, request.FailureReason, request.ChannelType); + + _logger.LogWarning( + "Dead-lettered delivery {DeliveryId} for tenant {TenantId}: {Reason}", + request.DeliveryId, request.TenantId, request.FailureReason); + + return Task.FromResult(entry); + } + + public Task GetAsync( + string tenantId, + string entryId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(entryId); + + _entries.TryGetValue(GetKey(tenantId, entryId), out var entry); + return Task.FromResult(entry); + } + + public Task> ListAsync( + string tenantId, + DeadLetterListOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + options ??= new DeadLetterListOptions(); + + var query = _entries.Values + .Where(e => e.TenantId == tenantId); + + if (options.Status.HasValue) + { + query = query.Where(e => e.Status == options.Status.Value); + } + + if (!string.IsNullOrWhiteSpace(options.ChannelId)) + { + query = query.Where(e => e.ChannelId == options.ChannelId); + } + + if (!string.IsNullOrWhiteSpace(options.ChannelType)) + { + query = query.Where(e => e.ChannelType == options.ChannelType); + } + + if (options.Since.HasValue) + { + query = query.Where(e => e.CreatedAt >= options.Since.Value); + } + + if (options.Until.HasValue) + { + query = query.Where(e => e.CreatedAt <= options.Until.Value); + } + + var result = query + .OrderByDescending(e => e.CreatedAt) + .Skip(options.Offset) + .Take(options.Limit) + .ToArray(); + + return Task.FromResult>(result); + } + + public Task RetryAsync( + string tenantId, + string entryId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(entryId); + + var key = GetKey(tenantId, entryId); + if (!_entries.TryGetValue(key, out var entry)) + { + return Task.FromResult(new DeadLetterRetryResult + { + EntryId = entryId, + Success = false, + Error = "Entry not found" + }); + } + + if (entry.Status is DeadLetterStatus.Retried or DeadLetterStatus.Resolved) + { + return Task.FromResult(new DeadLetterRetryResult + { + EntryId = entryId, + Success = false, + Error = $"Entry is already {entry.Status}" + }); + } + + var now = _timeProvider.GetUtcNow(); + + // Update entry status + var updatedEntry = entry with + { + Status = DeadLetterStatus.Retried, + RetryCount = entry.RetryCount + 1, + LastRetryAt = now + }; + + _entries[key] = updatedEntry; + + _logger.LogInformation( + "Retried dead-letter entry {EntryId} for tenant {TenantId}", + entryId, tenantId); + + // In a real implementation, this would re-queue the delivery + return Task.FromResult(new DeadLetterRetryResult + { + EntryId = entryId, + Success = true, + RetriedAt = now, + NewDeliveryId = Guid.NewGuid().ToString("N") + }); + } + + public async Task> RetryBatchAsync( + string tenantId, + IEnumerable entryIds, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(entryIds); + + var results = new List(); + foreach (var entryId in entryIds) + { + var result = await RetryAsync(tenantId, entryId, cancellationToken).ConfigureAwait(false); + results.Add(result); + } + + return results; + } + + public Task ResolveAsync( + string tenantId, + string entryId, + string resolution, + string? resolvedBy = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(entryId); + ArgumentException.ThrowIfNullOrWhiteSpace(resolution); + + var key = GetKey(tenantId, entryId); + if (_entries.TryGetValue(key, out var entry)) + { + var now = _timeProvider.GetUtcNow(); + _entries[key] = entry with + { + Status = DeadLetterStatus.Resolved, + Resolution = resolution, + ResolvedBy = resolvedBy, + ResolvedAt = now + }; + + _logger.LogInformation( + "Resolved dead-letter entry {EntryId} for tenant {TenantId}: {Resolution}", + entryId, tenantId, resolution); + } + + return Task.CompletedTask; + } + + public Task PurgeExpiredAsync( + string tenantId, + TimeSpan maxAge, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var cutoff = _timeProvider.GetUtcNow() - maxAge; + var toRemove = _entries + .Where(kv => kv.Value.TenantId == tenantId && kv.Value.CreatedAt < cutoff) + .Select(kv => kv.Key) + .ToArray(); + + var count = 0; + foreach (var key in toRemove) + { + if (_entries.TryRemove(key, out _)) + { + count++; + } + } + + if (count > 0) + { + _logger.LogInformation( + "Purged {Count} expired dead-letter entries for tenant {TenantId}", + count, tenantId); + } + + return Task.FromResult(count); + } + + public Task GetStatsAsync( + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var entries = _entries.Values.Where(e => e.TenantId == tenantId).ToArray(); + + var byChannel = entries + .GroupBy(e => e.ChannelType) + .ToDictionary(g => g.Key, g => g.Count()); + + var byReason = entries + .GroupBy(e => e.FailureReason) + .ToDictionary(g => g.Key, g => g.Count()); + + var stats = new DeadLetterStats + { + TotalCount = entries.Length, + PendingCount = entries.Count(e => e.Status == DeadLetterStatus.Pending), + RetryingCount = entries.Count(e => e.Status == DeadLetterStatus.Retrying), + RetriedCount = entries.Count(e => e.Status == DeadLetterStatus.Retried), + ResolvedCount = entries.Count(e => e.Status == DeadLetterStatus.Resolved), + ExhaustedCount = entries.Count(e => e.Status == DeadLetterStatus.Exhausted), + ByChannel = byChannel, + ByReason = byReason, + OldestEntryAt = entries.MinBy(e => e.CreatedAt)?.CreatedAt, + NewestEntryAt = entries.MaxBy(e => e.CreatedAt)?.CreatedAt + }; + + return Task.FromResult(stats); + } + + private static string GetKey(string tenantId, string entryId) => $"{tenantId}:{entryId}"; +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/DefaultNotifyMetrics.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/DefaultNotifyMetrics.cs new file mode 100644 index 000000000..ee01cf60f --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/DefaultNotifyMetrics.cs @@ -0,0 +1,233 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace StellaOps.Notifier.Worker.Observability; + +/// +/// Default implementation of notification metrics using System.Diagnostics.Metrics. +/// +public sealed class DefaultNotifyMetrics : INotifyMetrics +{ + private static readonly ActivitySource ActivitySource = new("StellaOps.Notifier", "1.0.0"); + private static readonly Meter Meter = new("StellaOps.Notifier", "1.0.0"); + + // Counters + private readonly Counter _deliveryAttempts; + private readonly Counter _escalationEvents; + private readonly Counter _deadLetterEntries; + private readonly Counter _ruleEvaluations; + private readonly Counter _templateRenders; + private readonly Counter _stormEvents; + private readonly Counter _retentionCleanups; + + // Histograms + private readonly Histogram _deliveryDuration; + private readonly Histogram _ruleEvaluationDuration; + private readonly Histogram _templateRenderDuration; + + // Gauges (using ObservableGauge pattern) + private readonly Dictionary _queueDepths = new(); + private readonly object _queueDepthLock = new(); + + public DefaultNotifyMetrics() + { + // Initialize counters + _deliveryAttempts = Meter.CreateCounter( + NotifyMetricNames.DeliveryAttempts, + unit: "{attempts}", + description: "Total number of notification delivery attempts"); + + _escalationEvents = Meter.CreateCounter( + NotifyMetricNames.EscalationEvents, + unit: "{events}", + description: "Total number of escalation events"); + + _deadLetterEntries = Meter.CreateCounter( + NotifyMetricNames.DeadLetterEntries, + unit: "{entries}", + description: "Total number of dead-letter entries"); + + _ruleEvaluations = Meter.CreateCounter( + NotifyMetricNames.RuleEvaluations, + unit: "{evaluations}", + description: "Total number of rule evaluations"); + + _templateRenders = Meter.CreateCounter( + NotifyMetricNames.TemplateRenders, + unit: "{renders}", + description: "Total number of template render operations"); + + _stormEvents = Meter.CreateCounter( + NotifyMetricNames.StormEvents, + unit: "{events}", + description: "Total number of storm detection events"); + + _retentionCleanups = Meter.CreateCounter( + NotifyMetricNames.RetentionCleanups, + unit: "{cleanups}", + description: "Total number of retention cleanup operations"); + + // Initialize histograms + _deliveryDuration = Meter.CreateHistogram( + NotifyMetricNames.DeliveryDuration, + unit: "ms", + description: "Duration of delivery attempts in milliseconds"); + + _ruleEvaluationDuration = Meter.CreateHistogram( + NotifyMetricNames.RuleEvaluationDuration, + unit: "ms", + description: "Duration of rule evaluations in milliseconds"); + + _templateRenderDuration = Meter.CreateHistogram( + NotifyMetricNames.TemplateRenderDuration, + unit: "ms", + description: "Duration of template renders in milliseconds"); + + // Initialize observable gauge for queue depths + Meter.CreateObservableGauge( + NotifyMetricNames.QueueDepth, + observeValues: ObserveQueueDepths, + unit: "{messages}", + description: "Current queue depth per channel"); + } + + public void RecordDeliveryAttempt(string tenantId, string channelType, string status, TimeSpan duration) + { + var tags = new TagList + { + { NotifyMetricTags.TenantId, tenantId }, + { NotifyMetricTags.ChannelType, channelType }, + { NotifyMetricTags.Status, status } + }; + + _deliveryAttempts.Add(1, tags); + _deliveryDuration.Record(duration.TotalMilliseconds, tags); + } + + public void RecordEscalation(string tenantId, int level, string outcome) + { + var tags = new TagList + { + { NotifyMetricTags.TenantId, tenantId }, + { NotifyMetricTags.Level, level.ToString() }, + { NotifyMetricTags.Outcome, outcome } + }; + + _escalationEvents.Add(1, tags); + } + + public void RecordDeadLetter(string tenantId, string reason, string channelType) + { + var tags = new TagList + { + { NotifyMetricTags.TenantId, tenantId }, + { NotifyMetricTags.Reason, reason }, + { NotifyMetricTags.ChannelType, channelType } + }; + + _deadLetterEntries.Add(1, tags); + } + + public void RecordRuleEvaluation(string tenantId, string ruleId, bool matched, TimeSpan duration) + { + var tags = new TagList + { + { NotifyMetricTags.TenantId, tenantId }, + { NotifyMetricTags.RuleId, ruleId }, + { NotifyMetricTags.Matched, matched.ToString().ToLowerInvariant() } + }; + + _ruleEvaluations.Add(1, tags); + _ruleEvaluationDuration.Record(duration.TotalMilliseconds, tags); + } + + public void RecordTemplateRender(string tenantId, string templateKey, bool success, TimeSpan duration) + { + var tags = new TagList + { + { NotifyMetricTags.TenantId, tenantId }, + { NotifyMetricTags.TemplateKey, templateKey }, + { NotifyMetricTags.Success, success.ToString().ToLowerInvariant() } + }; + + _templateRenders.Add(1, tags); + _templateRenderDuration.Record(duration.TotalMilliseconds, tags); + } + + public void RecordStormEvent(string tenantId, string stormKey, string decision) + { + var tags = new TagList + { + { NotifyMetricTags.TenantId, tenantId }, + { NotifyMetricTags.StormKey, stormKey }, + { NotifyMetricTags.Decision, decision } + }; + + _stormEvents.Add(1, tags); + } + + public void RecordRetentionCleanup(string tenantId, string entityType, int deletedCount) + { + var tags = new TagList + { + { NotifyMetricTags.TenantId, tenantId }, + { NotifyMetricTags.EntityType, entityType } + }; + + _retentionCleanups.Add(deletedCount, tags); + } + + public void RecordQueueDepth(string tenantId, string channelType, int depth) + { + var key = $"{tenantId}:{channelType}"; + lock (_queueDepthLock) + { + _queueDepths[key] = depth; + } + } + + public Activity? StartDeliveryActivity(string tenantId, string deliveryId, string channelType) + { + var activity = ActivitySource.StartActivity("notify.delivery", ActivityKind.Internal); + if (activity is not null) + { + activity.SetTag(NotifyMetricTags.TenantId, tenantId); + activity.SetTag("delivery_id", deliveryId); + activity.SetTag(NotifyMetricTags.ChannelType, channelType); + } + return activity; + } + + public Activity? StartEscalationActivity(string tenantId, string incidentId, int level) + { + var activity = ActivitySource.StartActivity("notify.escalation", ActivityKind.Internal); + if (activity is not null) + { + activity.SetTag(NotifyMetricTags.TenantId, tenantId); + activity.SetTag("incident_id", incidentId); + activity.SetTag(NotifyMetricTags.Level, level); + } + return activity; + } + + private IEnumerable> ObserveQueueDepths() + { + lock (_queueDepthLock) + { + foreach (var (key, depth) in _queueDepths) + { + var parts = key.Split(':'); + if (parts.Length == 2) + { + yield return new Measurement( + depth, + new TagList + { + { NotifyMetricTags.TenantId, parts[0] }, + { NotifyMetricTags.ChannelType, parts[1] } + }); + } + } + } + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/INotifyMetrics.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/INotifyMetrics.cs new file mode 100644 index 000000000..0b0b09ea5 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Observability/INotifyMetrics.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace StellaOps.Notifier.Worker.Observability; + +/// +/// Interface for notification system metrics and tracing. +/// +public interface INotifyMetrics +{ + /// + /// Records a notification delivery attempt. + /// + void RecordDeliveryAttempt(string tenantId, string channelType, string status, TimeSpan duration); + + /// + /// Records an escalation event. + /// + void RecordEscalation(string tenantId, int level, string outcome); + + /// + /// Records a dead-letter entry. + /// + void RecordDeadLetter(string tenantId, string reason, string channelType); + + /// + /// Records rule evaluation. + /// + void RecordRuleEvaluation(string tenantId, string ruleId, bool matched, TimeSpan duration); + + /// + /// Records template rendering. + /// + void RecordTemplateRender(string tenantId, string templateKey, bool success, TimeSpan duration); + + /// + /// Records storm detection event. + /// + void RecordStormEvent(string tenantId, string stormKey, string decision); + + /// + /// Records retention cleanup. + /// + void RecordRetentionCleanup(string tenantId, string entityType, int deletedCount); + + /// + /// Gets the current queue depth for a channel. + /// + void RecordQueueDepth(string tenantId, string channelType, int depth); + + /// + /// Creates an activity for distributed tracing. + /// + Activity? StartDeliveryActivity(string tenantId, string deliveryId, string channelType); + + /// + /// Creates an activity for escalation tracing. + /// + Activity? StartEscalationActivity(string tenantId, string incidentId, int level); +} + +/// +/// Metric tag names for consistency. +/// +public static class NotifyMetricTags +{ + public const string TenantId = "tenant_id"; + public const string ChannelType = "channel_type"; + public const string Status = "status"; + public const string Outcome = "outcome"; + public const string Level = "level"; + public const string Reason = "reason"; + public const string RuleId = "rule_id"; + public const string Matched = "matched"; + public const string TemplateKey = "template_key"; + public const string Success = "success"; + public const string StormKey = "storm_key"; + public const string Decision = "decision"; + public const string EntityType = "entity_type"; +} + +/// +/// Metric names for the notification system. +/// +public static class NotifyMetricNames +{ + public const string DeliveryAttempts = "notify.delivery.attempts"; + public const string DeliveryDuration = "notify.delivery.duration"; + public const string EscalationEvents = "notify.escalation.events"; + public const string DeadLetterEntries = "notify.deadletter.entries"; + public const string RuleEvaluations = "notify.rule.evaluations"; + public const string RuleEvaluationDuration = "notify.rule.evaluation.duration"; + public const string TemplateRenders = "notify.template.renders"; + public const string TemplateRenderDuration = "notify.template.render.duration"; + public const string StormEvents = "notify.storm.events"; + public const string RetentionCleanups = "notify.retention.cleanups"; + public const string QueueDepth = "notify.queue.depth"; +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/DefaultRetentionPolicyService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/DefaultRetentionPolicyService.cs new file mode 100644 index 000000000..acfd9ec6e --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/DefaultRetentionPolicyService.cs @@ -0,0 +1,298 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using StellaOps.Notifier.Worker.DeadLetter; +using StellaOps.Notifier.Worker.Observability; + +namespace StellaOps.Notifier.Worker.Retention; + +/// +/// Default implementation of retention policy service. +/// +public sealed class DefaultRetentionPolicyService : IRetentionPolicyService +{ + private readonly ConcurrentDictionary _policies = new(); + private readonly ConcurrentDictionary _lastExecutions = new(); + private readonly IDeadLetterService _deadLetterService; + private readonly TimeProvider _timeProvider; + private readonly INotifyMetrics? _metrics; + private readonly ILogger _logger; + + public DefaultRetentionPolicyService( + IDeadLetterService deadLetterService, + TimeProvider timeProvider, + ILogger logger, + INotifyMetrics? metrics = null) + { + _deadLetterService = deadLetterService ?? throw new ArgumentNullException(nameof(deadLetterService)); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _metrics = metrics; + } + + public Task GetPolicyAsync( + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var policy = _policies.GetValueOrDefault(tenantId, RetentionPolicy.Default); + return Task.FromResult(policy); + } + + public Task SetPolicyAsync( + string tenantId, + RetentionPolicy policy, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentNullException.ThrowIfNull(policy); + + _policies[tenantId] = policy; + + _logger.LogInformation( + "Updated retention policy for tenant {TenantId}: DeliveryRetention={DeliveryRetention}, AuditRetention={AuditRetention}", + tenantId, policy.DeliveryRetention, policy.AuditRetention); + + return Task.CompletedTask; + } + + public async Task ExecuteCleanupAsync( + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var executionId = Guid.NewGuid().ToString("N"); + var startedAt = _timeProvider.GetUtcNow(); + var policy = await GetPolicyAsync(tenantId, cancellationToken).ConfigureAwait(false); + + var execution = new RetentionCleanupExecution + { + ExecutionId = executionId, + TenantId = tenantId, + StartedAt = startedAt, + Status = RetentionCleanupStatus.Running, + PolicyUsed = policy + }; + + _lastExecutions[tenantId] = execution; + + _logger.LogInformation( + "Starting retention cleanup {ExecutionId} for tenant {TenantId}", + executionId, tenantId); + + try + { + var counts = await ExecuteCleanupInternalAsync(tenantId, policy, cancellationToken) + .ConfigureAwait(false); + + var completedAt = _timeProvider.GetUtcNow(); + var duration = completedAt - startedAt; + + execution = execution with + { + CompletedAt = completedAt, + Status = RetentionCleanupStatus.Completed, + Counts = counts + }; + + _lastExecutions[tenantId] = execution; + + _logger.LogInformation( + "Completed retention cleanup {ExecutionId} for tenant {TenantId}: {Total} items deleted in {Duration}ms", + executionId, tenantId, counts.Total, duration.TotalMilliseconds); + + return new RetentionCleanupResult + { + TenantId = tenantId, + Success = true, + ExecutedAt = startedAt, + Duration = duration, + Counts = counts + }; + } + catch (OperationCanceledException) + { + execution = execution with + { + CompletedAt = _timeProvider.GetUtcNow(), + Status = RetentionCleanupStatus.Cancelled, + Error = "Operation was cancelled" + }; + + _lastExecutions[tenantId] = execution; + + _logger.LogWarning( + "Retention cleanup {ExecutionId} for tenant {TenantId} was cancelled", + executionId, tenantId); + + return new RetentionCleanupResult + { + TenantId = tenantId, + Success = false, + Error = "Operation was cancelled", + ExecutedAt = startedAt, + Duration = _timeProvider.GetUtcNow() - startedAt, + Counts = new RetentionCleanupCounts() + }; + } + catch (Exception ex) + { + execution = execution with + { + CompletedAt = _timeProvider.GetUtcNow(), + Status = RetentionCleanupStatus.Failed, + Error = ex.Message + }; + + _lastExecutions[tenantId] = execution; + + _logger.LogError(ex, + "Retention cleanup {ExecutionId} for tenant {TenantId} failed", + executionId, tenantId); + + return new RetentionCleanupResult + { + TenantId = tenantId, + Success = false, + Error = ex.Message, + ExecutedAt = startedAt, + Duration = _timeProvider.GetUtcNow() - startedAt, + Counts = new RetentionCleanupCounts() + }; + } + } + + public async Task> ExecuteCleanupAllAsync( + CancellationToken cancellationToken = default) + { + var tenantIds = _policies.Keys.ToArray(); + var results = new List(); + + foreach (var tenantId in tenantIds) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await ExecuteCleanupAsync(tenantId, cancellationToken).ConfigureAwait(false); + results.Add(result); + } + + _logger.LogInformation( + "Completed retention cleanup for {Count} tenants: {Successful} successful, {Failed} failed", + results.Count, results.Count(r => r.Success), results.Count(r => !r.Success)); + + return results; + } + + public Task GetLastExecutionAsync( + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + _lastExecutions.TryGetValue(tenantId, out var execution); + return Task.FromResult(execution); + } + + public async Task PreviewCleanupAsync( + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + var policy = await GetPolicyAsync(tenantId, cancellationToken).ConfigureAwait(false); + var now = _timeProvider.GetUtcNow(); + + var cutoffDates = new Dictionary + { + ["Deliveries"] = now - policy.DeliveryRetention, + ["AuditEntries"] = now - policy.AuditRetention, + ["DeadLetterEntries"] = now - policy.DeadLetterRetention, + ["StormData"] = now - policy.StormDataRetention, + ["InboxMessages"] = now - policy.InboxRetention, + ["Events"] = now - policy.EventHistoryRetention + }; + + // Get estimated dead-letter count + var deadLetterStats = await _deadLetterService.GetStatsAsync(tenantId, cancellationToken) + .ConfigureAwait(false); + + // Estimate counts based on age distribution (simplified - in production would query actual counts) + var estimatedCounts = new RetentionCleanupCounts + { + DeadLetterEntries = EstimateExpiredCount(deadLetterStats, policy.DeadLetterRetention, now) + }; + + return new RetentionCleanupPreview + { + TenantId = tenantId, + PreviewedAt = now, + EstimatedCounts = estimatedCounts, + PolicyApplied = policy, + CutoffDates = cutoffDates + }; + } + + private async Task ExecuteCleanupInternalAsync( + string tenantId, + RetentionPolicy policy, + CancellationToken cancellationToken) + { + var deadLetterCount = 0; + + // Purge expired dead-letter entries + deadLetterCount = await _deadLetterService.PurgeExpiredAsync( + tenantId, + policy.DeadLetterRetention, + cancellationToken).ConfigureAwait(false); + + if (deadLetterCount > 0) + { + _metrics?.RecordRetentionCleanup(tenantId, "DeadLetter", deadLetterCount); + } + + // In a full implementation, we would also clean up: + // - Delivery records from delivery store + // - Audit log entries from audit store + // - Storm tracking data from storm store + // - Inbox messages from inbox store + // - Event history from event store + + // For now, return counts with just dead-letter cleanup + return new RetentionCleanupCounts + { + DeadLetterEntries = deadLetterCount + }; + } + + private static int EstimateExpiredCount(DeadLetterStats stats, TimeSpan retention, DateTimeOffset now) + { + if (!stats.OldestEntryAt.HasValue) + { + return 0; + } + + var cutoff = now - retention; + if (stats.OldestEntryAt.Value >= cutoff) + { + return 0; + } + + // Rough estimation - assume linear distribution + if (!stats.NewestEntryAt.HasValue || stats.TotalCount == 0) + { + return 0; + } + + var totalSpan = stats.NewestEntryAt.Value - stats.OldestEntryAt.Value; + if (totalSpan.TotalSeconds <= 0) + { + return stats.TotalCount; + } + + var expiredSpan = cutoff - stats.OldestEntryAt.Value; + var ratio = Math.Clamp(expiredSpan.TotalSeconds / totalSpan.TotalSeconds, 0, 1); + + return (int)(stats.TotalCount * ratio); + } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/IRetentionPolicyService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/IRetentionPolicyService.cs new file mode 100644 index 000000000..3b18ada42 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Retention/IRetentionPolicyService.cs @@ -0,0 +1,181 @@ +namespace StellaOps.Notifier.Worker.Retention; + +/// +/// Service for managing data retention policies and cleanup. +/// +public interface IRetentionPolicyService +{ + /// + /// Gets the retention policy for a tenant. + /// + Task GetPolicyAsync( + string tenantId, + CancellationToken cancellationToken = default); + + /// + /// Sets/updates the retention policy for a tenant. + /// + Task SetPolicyAsync( + string tenantId, + RetentionPolicy policy, + CancellationToken cancellationToken = default); + + /// + /// Executes retention cleanup for a tenant. + /// + Task ExecuteCleanupAsync( + string tenantId, + CancellationToken cancellationToken = default); + + /// + /// Executes retention cleanup for all tenants. + /// + Task> ExecuteCleanupAllAsync( + CancellationToken cancellationToken = default); + + /// + /// Gets the last cleanup execution details. + /// + Task GetLastExecutionAsync( + string tenantId, + CancellationToken cancellationToken = default); + + /// + /// Previews what would be cleaned up without actually deleting. + /// + Task PreviewCleanupAsync( + string tenantId, + CancellationToken cancellationToken = default); +} + +/// +/// Data retention policy configuration. +/// +public sealed record RetentionPolicy +{ + /// + /// Retention period for delivery records. + /// + public TimeSpan DeliveryRetention { get; init; } = TimeSpan.FromDays(90); + + /// + /// Retention period for audit log entries. + /// + public TimeSpan AuditRetention { get; init; } = TimeSpan.FromDays(365); + + /// + /// Retention period for dead-letter entries. + /// + public TimeSpan DeadLetterRetention { get; init; } = TimeSpan.FromDays(30); + + /// + /// Retention period for storm tracking data. + /// + public TimeSpan StormDataRetention { get; init; } = TimeSpan.FromDays(7); + + /// + /// Retention period for inbox messages. + /// + public TimeSpan InboxRetention { get; init; } = TimeSpan.FromDays(30); + + /// + /// Retention period for event history. + /// + public TimeSpan EventHistoryRetention { get; init; } = TimeSpan.FromDays(30); + + /// + /// Whether automatic cleanup is enabled. + /// + public bool AutoCleanupEnabled { get; init; } = true; + + /// + /// Cron expression for automatic cleanup schedule. + /// + public string CleanupSchedule { get; init; } = "0 2 * * *"; // Daily at 2 AM + + /// + /// Maximum records to delete per cleanup run. + /// + public int MaxDeletesPerRun { get; init; } = 10000; + + /// + /// Whether to keep resolved/acknowledged deliveries longer. + /// + public bool ExtendResolvedRetention { get; init; } = true; + + /// + /// Extension multiplier for resolved items (e.g., 2x = double the retention). + /// + public double ResolvedRetentionMultiplier { get; init; } = 2.0; + + /// + /// Default policy with standard retention periods. + /// + public static RetentionPolicy Default => new(); +} + +/// +/// Result of a retention cleanup execution. +/// +public sealed record RetentionCleanupResult +{ + public required string TenantId { get; init; } + public required bool Success { get; init; } + public string? Error { get; init; } + public required DateTimeOffset ExecutedAt { get; init; } + public TimeSpan Duration { get; init; } + public required RetentionCleanupCounts Counts { get; init; } +} + +/// +/// Counts of items deleted during retention cleanup. +/// +public sealed record RetentionCleanupCounts +{ + public int Deliveries { get; init; } + public int AuditEntries { get; init; } + public int DeadLetterEntries { get; init; } + public int StormData { get; init; } + public int InboxMessages { get; init; } + public int Events { get; init; } + + public int Total => Deliveries + AuditEntries + DeadLetterEntries + StormData + InboxMessages + Events; +} + +/// +/// Details of a cleanup execution. +/// +public sealed record RetentionCleanupExecution +{ + public required string ExecutionId { get; init; } + public required string TenantId { get; init; } + public required DateTimeOffset StartedAt { get; init; } + public DateTimeOffset? CompletedAt { get; init; } + public required RetentionCleanupStatus Status { get; init; } + public RetentionCleanupCounts? Counts { get; init; } + public string? Error { get; init; } + public RetentionPolicy PolicyUsed { get; init; } = RetentionPolicy.Default; +} + +/// +/// Status of a cleanup execution. +/// +public enum RetentionCleanupStatus +{ + Running, + Completed, + Failed, + Cancelled +} + +/// +/// Preview of what would be cleaned up. +/// +public sealed record RetentionCleanupPreview +{ + public required string TenantId { get; init; } + public required DateTimeOffset PreviewedAt { get; init; } + public required RetentionCleanupCounts EstimatedCounts { get; init; } + public required RetentionPolicy PolicyApplied { get; init; } + public required IReadOnlyDictionary CutoffDates { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultHtmlSanitizer.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultHtmlSanitizer.cs new file mode 100644 index 000000000..5e4eb6bac --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultHtmlSanitizer.cs @@ -0,0 +1,509 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace StellaOps.Notifier.Worker.Security; + +/// +/// Default HTML sanitizer implementation using regex-based filtering. +/// For production, consider using a dedicated library like HtmlSanitizer or AngleSharp. +/// +public sealed partial class DefaultHtmlSanitizer : IHtmlSanitizer +{ + private readonly ILogger _logger; + + // Safe elements (whitelist approach) + private static readonly HashSet SafeElements = new(StringComparer.OrdinalIgnoreCase) + { + "p", "div", "span", "br", "hr", + "h1", "h2", "h3", "h4", "h5", "h6", + "strong", "b", "em", "i", "u", "s", "strike", + "ul", "ol", "li", "dl", "dt", "dd", + "table", "thead", "tbody", "tfoot", "tr", "th", "td", + "a", "img", + "blockquote", "pre", "code", + "sub", "sup", "small", "mark", + "caption", "figure", "figcaption" + }; + + // Safe attributes + private static readonly HashSet SafeAttributes = new(StringComparer.OrdinalIgnoreCase) + { + "href", "src", "alt", "title", "class", "id", + "width", "height", "style", + "colspan", "rowspan", "scope", + "target", "rel" + }; + + // Dangerous URL schemes + private static readonly HashSet DangerousSchemes = new(StringComparer.OrdinalIgnoreCase) + { + "javascript", "vbscript", "data", "file" + }; + + // Event handler attributes (all start with "on") + private static readonly Regex EventHandlerRegex = EventHandlerPattern(); + + // Style-based attacks + private static readonly Regex DangerousStyleRegex = DangerousStylePattern(); + + public DefaultHtmlSanitizer(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Sanitize(string html, HtmlSanitizeOptions? options = null) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + options ??= new HtmlSanitizeOptions(); + + if (html.Length > options.MaxContentLength) + { + _logger.LogWarning("HTML content exceeds max length {MaxLength}, truncating", options.MaxContentLength); + html = html[..options.MaxContentLength]; + } + + var allowedTags = new HashSet(SafeElements, StringComparer.OrdinalIgnoreCase); + if (options.AdditionalAllowedTags is not null) + { + foreach (var tag in options.AdditionalAllowedTags) + { + allowedTags.Add(tag); + } + } + + var allowedAttrs = new HashSet(SafeAttributes, StringComparer.OrdinalIgnoreCase); + if (options.AdditionalAllowedAttributes is not null) + { + foreach (var attr in options.AdditionalAllowedAttributes) + { + allowedAttrs.Add(attr); + } + } + + // Process HTML + var result = new StringBuilder(); + var depth = 0; + var pos = 0; + + while (pos < html.Length) + { + var tagStart = html.IndexOf('<', pos); + if (tagStart < 0) + { + // No more tags, append rest + result.Append(EncodeText(html[pos..])); + break; + } + + // Append text before tag + if (tagStart > pos) + { + result.Append(EncodeText(html[pos..tagStart])); + } + + var tagEnd = html.IndexOf('>', tagStart); + if (tagEnd < 0) + { + // Malformed, skip rest + break; + } + + var tagContent = html[(tagStart + 1)..tagEnd]; + var isClosing = tagContent.StartsWith('/'); + var tagName = ExtractTagName(tagContent); + + if (isClosing) + { + depth--; + } + + if (allowedTags.Contains(tagName)) + { + if (isClosing) + { + result.Append($""); + } + else + { + // Process attributes + var sanitizedTag = SanitizeTag(tagContent, tagName, allowedAttrs, options); + result.Append($"<{sanitizedTag}>"); + + if (!IsSelfClosing(tagName) && !tagContent.EndsWith('/')) + { + depth++; + } + } + } + else + { + _logger.LogDebug("Stripped disallowed tag: {TagName}", tagName); + } + + if (depth > options.MaxNestingDepth) + { + _logger.LogWarning("HTML nesting depth exceeds max {MaxDepth}, truncating", options.MaxNestingDepth); + break; + } + + pos = tagEnd + 1; + } + + return result.ToString(); + } + + public HtmlValidationResult Validate(string html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return HtmlValidationResult.Safe(new HtmlContentStats()); + } + + var issues = new List(); + var stats = new HtmlContentStats + { + CharacterCount = html.Length + }; + + var pos = 0; + var depth = 0; + var maxDepth = 0; + var elementCount = 0; + var linkCount = 0; + var imageCount = 0; + + // Check for script tags + if (ScriptTagRegex().IsMatch(html)) + { + issues.Add(new HtmlSecurityIssue + { + Type = HtmlSecurityIssueType.ScriptInjection, + Description = "Script tags are not allowed" + }); + } + + // Check for event handlers + var eventMatches = EventHandlerRegex.Matches(html); + foreach (Match match in eventMatches) + { + issues.Add(new HtmlSecurityIssue + { + Type = HtmlSecurityIssueType.EventHandler, + Description = "Event handler attributes are not allowed", + AttributeName = match.Value, + Position = match.Index + }); + } + + // Check for dangerous URLs + var hrefMatches = DangerousUrlRegex().Matches(html); + foreach (Match match in hrefMatches) + { + issues.Add(new HtmlSecurityIssue + { + Type = HtmlSecurityIssueType.DangerousUrl, + Description = "Dangerous URL scheme detected", + Position = match.Index + }); + } + + // Check for dangerous style content + var styleMatches = DangerousStyleRegex.Matches(html); + foreach (Match match in styleMatches) + { + issues.Add(new HtmlSecurityIssue + { + Type = HtmlSecurityIssueType.StyleInjection, + Description = "Dangerous style content detected", + Position = match.Index + }); + } + + // Check for dangerous elements + var dangerousElements = new[] { "iframe", "object", "embed", "form", "input", "button", "meta", "link", "base" }; + foreach (var element in dangerousElements) + { + var elementRegex = new Regex($@"<{element}\b", RegexOptions.IgnoreCase); + if (elementRegex.IsMatch(html)) + { + issues.Add(new HtmlSecurityIssue + { + Type = HtmlSecurityIssueType.DangerousElement, + Description = $"Dangerous element '{element}' is not allowed", + ElementName = element + }); + } + } + + // Count elements and check nesting + while (pos < html.Length) + { + var tagStart = html.IndexOf('<', pos); + if (tagStart < 0) break; + + var tagEnd = html.IndexOf('>', tagStart); + if (tagEnd < 0) break; + + var tagContent = html[(tagStart + 1)..tagEnd]; + var isClosing = tagContent.StartsWith('/'); + var tagName = ExtractTagName(tagContent); + + if (!isClosing && !string.IsNullOrEmpty(tagName) && !tagContent.EndsWith('/')) + { + if (!IsSelfClosing(tagName)) + { + depth++; + maxDepth = Math.Max(maxDepth, depth); + } + elementCount++; + + if (tagName.Equals("a", StringComparison.OrdinalIgnoreCase)) linkCount++; + if (tagName.Equals("img", StringComparison.OrdinalIgnoreCase)) imageCount++; + } + else if (isClosing) + { + depth--; + } + + pos = tagEnd + 1; + } + + stats = stats with + { + ElementCount = elementCount, + MaxDepth = maxDepth, + LinkCount = linkCount, + ImageCount = imageCount + }; + + return issues.Count == 0 + ? HtmlValidationResult.Safe(stats) + : HtmlValidationResult.Unsafe(issues, stats); + } + + public string StripHtml(string html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + // Remove all tags + var text = HtmlTagRegex().Replace(html, " "); + + // Decode entities + text = System.Net.WebUtility.HtmlDecode(text); + + // Normalize whitespace + text = WhitespaceRegex().Replace(text, " ").Trim(); + + return text; + } + + private static string SanitizeTag( + string tagContent, + string tagName, + HashSet allowedAttrs, + HtmlSanitizeOptions options) + { + var result = new StringBuilder(tagName); + + // Extract and sanitize attributes + var attrMatches = AttributeRegex().Matches(tagContent); + foreach (Match match in attrMatches) + { + var attrName = match.Groups[1].Value; + var attrValue = match.Groups[2].Value; + + if (!allowedAttrs.Contains(attrName)) + { + continue; + } + + // Skip event handlers + if (EventHandlerRegex.IsMatch(attrName)) + { + continue; + } + + // Sanitize href/src values + if (attrName.Equals("href", StringComparison.OrdinalIgnoreCase) || + attrName.Equals("src", StringComparison.OrdinalIgnoreCase)) + { + attrValue = SanitizeUrl(attrValue, options); + if (string.IsNullOrEmpty(attrValue)) + { + continue; + } + } + + // Sanitize style values + if (attrName.Equals("style", StringComparison.OrdinalIgnoreCase)) + { + attrValue = SanitizeStyle(attrValue); + if (string.IsNullOrEmpty(attrValue)) + { + continue; + } + } + + result.Append($" {attrName}=\"{EncodeAttributeValue(attrValue)}\""); + } + + // Add rel="noopener noreferrer" to links with target + if (tagName.Equals("a", StringComparison.OrdinalIgnoreCase) && + tagContent.Contains("target=", StringComparison.OrdinalIgnoreCase)) + { + if (!tagContent.Contains("rel=", StringComparison.OrdinalIgnoreCase)) + { + result.Append(" rel=\"noopener noreferrer\""); + } + } + + if (tagContent.TrimEnd().EndsWith('/')) + { + result.Append(" /"); + } + + return result.ToString(); + } + + private static string SanitizeUrl(string url, HtmlSanitizeOptions options) + { + if (string.IsNullOrWhiteSpace(url)) + { + return string.Empty; + } + + url = url.Trim(); + + // Check for dangerous schemes + var colonIndex = url.IndexOf(':'); + if (colonIndex > 0 && colonIndex < 10) + { + var scheme = url[..colonIndex].ToLowerInvariant(); + if (DangerousSchemes.Contains(scheme)) + { + if (scheme == "data" && options.AllowDataUrls) + { + // Allow data URLs if explicitly enabled + return url; + } + return string.Empty; + } + } + + // Allow relative URLs and safe absolute URLs + if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("tel:", StringComparison.OrdinalIgnoreCase) || + url.StartsWith('/') || + url.StartsWith('#') || + !url.Contains(':')) + { + return url; + } + + return string.Empty; + } + + private static string SanitizeStyle(string style) + { + if (string.IsNullOrWhiteSpace(style)) + { + return string.Empty; + } + + // Remove dangerous CSS + if (DangerousStyleRegex.IsMatch(style)) + { + return string.Empty; + } + + // Only allow simple property:value pairs + var safeProperties = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "color", "background-color", "font-size", "font-weight", "font-style", + "text-align", "text-decoration", "margin", "padding", "border", + "width", "height", "max-width", "max-height", "display" + }; + + var result = new StringBuilder(); + var pairs = style.Split(';', StringSplitOptions.RemoveEmptyEntries); + + foreach (var pair in pairs) + { + var colonIndex = pair.IndexOf(':'); + if (colonIndex <= 0) continue; + + var property = pair[..colonIndex].Trim().ToLowerInvariant(); + var value = pair[(colonIndex + 1)..].Trim(); + + if (safeProperties.Contains(property) && !value.Contains("url(", StringComparison.OrdinalIgnoreCase)) + { + if (result.Length > 0) result.Append("; "); + result.Append($"{property}: {value}"); + } + } + + return result.ToString(); + } + + private static string ExtractTagName(string tagContent) + { + var content = tagContent.TrimStart('/').Trim(); + var spaceIndex = content.IndexOfAny([' ', '\t', '\n', '\r', '/']); + return spaceIndex > 0 ? content[..spaceIndex] : content; + } + + private static bool IsSelfClosing(string tagName) + { + return tagName.Equals("br", StringComparison.OrdinalIgnoreCase) || + tagName.Equals("hr", StringComparison.OrdinalIgnoreCase) || + tagName.Equals("img", StringComparison.OrdinalIgnoreCase) || + tagName.Equals("input", StringComparison.OrdinalIgnoreCase) || + tagName.Equals("meta", StringComparison.OrdinalIgnoreCase) || + tagName.Equals("link", StringComparison.OrdinalIgnoreCase); + } + + private static string EncodeText(string text) + { + return System.Net.WebUtility.HtmlEncode(text); + } + + private static string EncodeAttributeValue(string value) + { + return value + .Replace("&", "&") + .Replace("\"", """) + .Replace("<", "<") + .Replace(">", ">"); + } + + [GeneratedRegex(@"\bon\w+\s*=", RegexOptions.IgnoreCase)] + private static partial Regex EventHandlerPattern(); + + [GeneratedRegex(@"expression\s*\(|behavior\s*:|@import|@charset|binding\s*:", RegexOptions.IgnoreCase)] + private static partial Regex DangerousStylePattern(); + + [GeneratedRegex(@"]*>")] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + [GeneratedRegex(@"(\w+)\s*=\s*""([^""]*)""", RegexOptions.Compiled)] + private static partial Regex AttributeRegex(); +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultTenantIsolationValidator.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultTenantIsolationValidator.cs new file mode 100644 index 000000000..15ce7edf2 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultTenantIsolationValidator.cs @@ -0,0 +1,221 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Notifier.Worker.Security; + +/// +/// Default implementation of tenant isolation validation. +/// +public sealed partial class DefaultTenantIsolationValidator : ITenantIsolationValidator +{ + private readonly TenantIsolationOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly ConcurrentQueue _violations = new(); + + // Valid tenant ID pattern: alphanumeric, hyphens, underscores, 3-64 chars + private static readonly Regex TenantIdPattern = TenantIdRegex(); + + public DefaultTenantIsolationValidator( + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _options = options?.Value ?? new TenantIsolationOptions(); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public TenantIsolationResult ValidateAccess( + string requestTenantId, + string resourceTenantId, + string resourceType, + string resourceId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requestTenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceTenantId); + + // Normalize tenant IDs + var normalizedRequest = NormalizeTenantId(requestTenantId); + var normalizedResource = NormalizeTenantId(resourceTenantId); + + // Check for exact match + if (string.Equals(normalizedRequest, normalizedResource, StringComparison.OrdinalIgnoreCase)) + { + return TenantIsolationResult.Allow(requestTenantId, resourceTenantId); + } + + // Check for cross-tenant access exceptions (admin tenants, shared resources) + if (_options.AllowCrossTenantAccess && + _options.CrossTenantAllowedPairs.Contains($"{normalizedRequest}:{normalizedResource}")) + { + _logger.LogDebug( + "Cross-tenant access allowed: {RequestTenant} -> {ResourceTenant} for {ResourceType}", + requestTenantId, resourceTenantId, resourceType); + return TenantIsolationResult.Allow(requestTenantId, resourceTenantId); + } + + // Check if request tenant is an admin tenant + if (_options.AdminTenants.Contains(normalizedRequest)) + { + _logger.LogInformation( + "Admin tenant {AdminTenant} accessing resource from {ResourceTenant}", + requestTenantId, resourceTenantId); + return TenantIsolationResult.Allow(requestTenantId, resourceTenantId); + } + + // Violation detected + var violation = new TenantIsolationViolation + { + OccurredAt = _timeProvider.GetUtcNow(), + RequestTenantId = requestTenantId, + ResourceTenantId = resourceTenantId, + ResourceType = resourceType, + ResourceId = resourceId, + Operation = "access" + }; + + RecordViolation(violation); + + _logger.LogWarning( + "Tenant isolation violation: {RequestTenant} attempted to access {ResourceType}/{ResourceId} belonging to {ResourceTenant}", + requestTenantId, resourceType, resourceId, resourceTenantId); + + return TenantIsolationResult.Deny( + requestTenantId, + resourceTenantId, + "Cross-tenant access denied", + resourceType, + resourceId); + } + + public IReadOnlyList ValidateBatch( + string requestTenantId, + IEnumerable resources) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requestTenantId); + ArgumentNullException.ThrowIfNull(resources); + + return resources + .Select(r => ValidateAccess(requestTenantId, r.TenantId, r.ResourceType, r.ResourceId)) + .ToArray(); + } + + public string? SanitizeTenantId(string? tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return null; + } + + var sanitized = tenantId.Trim(); + + // Remove any control characters + sanitized = ControlCharsRegex().Replace(sanitized, ""); + + // Check format + if (!TenantIdPattern.IsMatch(sanitized)) + { + _logger.LogWarning("Invalid tenant ID format: {TenantId}", tenantId); + return null; + } + + return sanitized; + } + + public bool IsValidTenantIdFormat(string? tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return false; + } + + return TenantIdPattern.IsMatch(tenantId.Trim()); + } + + public void RecordViolation(TenantIsolationViolation violation) + { + ArgumentNullException.ThrowIfNull(violation); + + _violations.Enqueue(violation); + + // Keep only recent violations + while (_violations.Count > _options.MaxStoredViolations) + { + _violations.TryDequeue(out _); + } + + // Emit metrics + TenantIsolationMetrics.RecordViolation( + violation.RequestTenantId, + violation.ResourceTenantId, + violation.ResourceType); + } + + public IReadOnlyList GetRecentViolations(int limit = 100) + { + return _violations.TakeLast(Math.Min(limit, _options.MaxStoredViolations)).ToArray(); + } + + private static string NormalizeTenantId(string tenantId) + { + return tenantId.Trim().ToLowerInvariant(); + } + + [GeneratedRegex(@"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$")] + private static partial Regex TenantIdRegex(); + + [GeneratedRegex(@"[\x00-\x1F\x7F]")] + private static partial Regex ControlCharsRegex(); +} + +/// +/// Configuration options for tenant isolation. +/// +public sealed class TenantIsolationOptions +{ + /// + /// Whether to allow any cross-tenant access. + /// + public bool AllowCrossTenantAccess { get; set; } + + /// + /// Pairs of tenants allowed to access each other's resources. + /// Format: "tenant1:tenant2" means tenant1 can access tenant2's resources. + /// + public HashSet CrossTenantAllowedPairs { get; set; } = []; + + /// + /// Tenants with admin access to all resources. + /// + public HashSet AdminTenants { get; set; } = []; + + /// + /// Maximum number of violations to store in memory. + /// + public int MaxStoredViolations { get; set; } = 1000; + + /// + /// Whether to throw exceptions on violations (vs returning result). + /// + public bool ThrowOnViolation { get; set; } +} + +/// +/// Metrics for tenant isolation. +/// +internal static class TenantIsolationMetrics +{ + // In a real implementation, these would emit to metrics system + private static long _violationCount; + + public static void RecordViolation(string requestTenant, string resourceTenant, string resourceType) + { + Interlocked.Increment(ref _violationCount); + // In production: emit to Prometheus/StatsD/etc. + } + + public static long GetViolationCount() => _violationCount; +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultWebhookSecurityService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultWebhookSecurityService.cs new file mode 100644 index 000000000..ecd44d164 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/DefaultWebhookSecurityService.cs @@ -0,0 +1,329 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Notifier.Worker.Security; + +/// +/// Default implementation of webhook security service using HMAC-SHA256. +/// +public sealed class DefaultWebhookSecurityService : IWebhookSecurityService +{ + private const string SignaturePrefix = "v1"; + private const int TimestampToleranceSeconds = 300; // 5 minutes + + private readonly WebhookSecurityOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + // In-memory storage for channel secrets (in production, use persistent storage) + private readonly ConcurrentDictionary _channelConfigs = new(); + + public DefaultWebhookSecurityService( + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _options = options?.Value ?? new WebhookSecurityOptions(); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string SignPayload(string tenantId, string channelId, ReadOnlySpan payload, DateTimeOffset timestamp) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(channelId); + + var config = GetOrCreateConfig(tenantId, channelId); + var timestampUnix = timestamp.ToUnixTimeSeconds(); + + // Create signed payload: timestamp.payload + var signedData = CreateSignedData(timestampUnix, payload); + + using var hmac = new HMACSHA256(config.SecretBytes); + var signature = hmac.ComputeHash(signedData); + var signatureHex = Convert.ToHexString(signature).ToLowerInvariant(); + + // Format: v1=timestamp,signature + return $"{SignaturePrefix}={timestampUnix},{signatureHex}"; + } + + public bool VerifySignature(string tenantId, string channelId, ReadOnlySpan payload, string signatureHeader) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(channelId); + + if (string.IsNullOrWhiteSpace(signatureHeader)) + { + _logger.LogWarning("Missing signature header for webhook callback"); + return false; + } + + // Parse header: v1=timestamp,signature + if (!signatureHeader.StartsWith($"{SignaturePrefix}=", StringComparison.Ordinal)) + { + _logger.LogWarning("Invalid signature prefix in header"); + return false; + } + + var parts = signatureHeader[(SignaturePrefix.Length + 1)..].Split(','); + if (parts.Length != 2) + { + _logger.LogWarning("Invalid signature format in header"); + return false; + } + + if (!long.TryParse(parts[0], out var timestampUnix)) + { + _logger.LogWarning("Invalid timestamp in signature header"); + return false; + } + + // Check timestamp is within tolerance + var now = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); + if (Math.Abs(now - timestampUnix) > TimestampToleranceSeconds) + { + _logger.LogWarning( + "Signature timestamp {Timestamp} is outside tolerance window (now: {Now})", + timestampUnix, now); + return false; + } + + byte[] providedSignature; + try + { + providedSignature = Convert.FromHexString(parts[1]); + } + catch (FormatException) + { + _logger.LogWarning("Invalid signature hex encoding"); + return false; + } + + var config = GetOrCreateConfig(tenantId, channelId); + var signedData = CreateSignedData(timestampUnix, payload); + + using var hmac = new HMACSHA256(config.SecretBytes); + var expectedSignature = hmac.ComputeHash(signedData); + + // Also check previous secret if within rotation window + if (!CryptographicOperations.FixedTimeEquals(expectedSignature, providedSignature)) + { + if (config.PreviousSecretBytes is not null && + config.PreviousSecretExpiresAt.HasValue && + _timeProvider.GetUtcNow() < config.PreviousSecretExpiresAt.Value) + { + using var hmacPrev = new HMACSHA256(config.PreviousSecretBytes); + var prevSignature = hmacPrev.ComputeHash(signedData); + return CryptographicOperations.FixedTimeEquals(prevSignature, providedSignature); + } + + return false; + } + + return true; + } + + public IpValidationResult ValidateIp(string tenantId, string channelId, IPAddress ipAddress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(channelId); + ArgumentNullException.ThrowIfNull(ipAddress); + + var config = GetOrCreateConfig(tenantId, channelId); + + if (config.IpAllowlist.Count == 0) + { + // No allowlist configured - allow all + return IpValidationResult.Allow(hasAllowlist: false); + } + + foreach (var entry in config.IpAllowlist) + { + if (IsIpInRange(ipAddress, entry.CidrOrIp)) + { + return IpValidationResult.Allow(entry.CidrOrIp, hasAllowlist: true); + } + } + + _logger.LogWarning( + "IP {IpAddress} not in allowlist for channel {ChannelId}", + ipAddress, channelId); + + return IpValidationResult.Deny($"IP {ipAddress} not in allowlist"); + } + + public string GetMaskedSecret(string tenantId, string channelId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(channelId); + + var config = GetOrCreateConfig(tenantId, channelId); + var secret = config.Secret; + + if (secret.Length <= 8) + { + return "****"; + } + + return $"{secret[..4]}...{secret[^4..]}"; + } + + public Task RotateSecretAsync( + string tenantId, + string channelId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(channelId); + + var key = GetConfigKey(tenantId, channelId); + var now = _timeProvider.GetUtcNow(); + var newSecret = GenerateSecret(); + + var result = _channelConfigs.AddOrUpdate( + key, + _ => new ChannelSecurityConfig(newSecret), + (_, existing) => + { + return new ChannelSecurityConfig(newSecret) + { + PreviousSecret = existing.Secret, + PreviousSecretBytes = existing.SecretBytes, + PreviousSecretExpiresAt = now.Add(_options.SecretRotationGracePeriod), + IpAllowlist = existing.IpAllowlist + }; + }); + + _logger.LogInformation( + "Rotated webhook secret for channel {ChannelId}, old secret valid until {ExpiresAt}", + channelId, result.PreviousSecretExpiresAt); + + return Task.FromResult(new WebhookSecretRotationResult + { + Success = true, + NewSecret = newSecret, + ActiveAt = now, + OldSecretExpiresAt = result.PreviousSecretExpiresAt + }); + } + + private ChannelSecurityConfig GetOrCreateConfig(string tenantId, string channelId) + { + var key = GetConfigKey(tenantId, channelId); + return _channelConfigs.GetOrAdd(key, _ => new ChannelSecurityConfig(GenerateSecret())); + } + + private static string GetConfigKey(string tenantId, string channelId) + => $"{tenantId}:{channelId}"; + + private static string GenerateSecret() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes); + } + + private static byte[] CreateSignedData(long timestamp, ReadOnlySpan payload) + { + var timestampBytes = Encoding.UTF8.GetBytes(timestamp.ToString()); + var result = new byte[timestampBytes.Length + 1 + payload.Length]; + timestampBytes.CopyTo(result, 0); + result[timestampBytes.Length] = (byte)'.'; + payload.CopyTo(result.AsSpan(timestampBytes.Length + 1)); + return result; + } + + private static bool IsIpInRange(IPAddress ip, string cidrOrIp) + { + if (cidrOrIp.Contains('/')) + { + // CIDR notation + var parts = cidrOrIp.Split('/'); + if (!IPAddress.TryParse(parts[0], out var networkAddress) || + !int.TryParse(parts[1], out var prefixLength)) + { + return false; + } + + return IsInSubnet(ip, networkAddress, prefixLength); + } + else + { + // Single IP + return IPAddress.TryParse(cidrOrIp, out var singleIp) && ip.Equals(singleIp); + } + } + + private static bool IsInSubnet(IPAddress ip, IPAddress network, int prefixLength) + { + var ipBytes = ip.GetAddressBytes(); + var networkBytes = network.GetAddressBytes(); + + if (ipBytes.Length != networkBytes.Length) + { + return false; + } + + var fullBytes = prefixLength / 8; + var remainingBits = prefixLength % 8; + + for (var i = 0; i < fullBytes; i++) + { + if (ipBytes[i] != networkBytes[i]) + { + return false; + } + } + + if (remainingBits > 0 && fullBytes < ipBytes.Length) + { + var mask = (byte)(0xFF << (8 - remainingBits)); + if ((ipBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask)) + { + return false; + } + } + + return true; + } + + private sealed class ChannelSecurityConfig + { + public ChannelSecurityConfig(string secret) + { + Secret = secret; + SecretBytes = Encoding.UTF8.GetBytes(secret); + } + + public string Secret { get; } + public byte[] SecretBytes { get; } + public string? PreviousSecret { get; init; } + public byte[]? PreviousSecretBytes { get; init; } + public DateTimeOffset? PreviousSecretExpiresAt { get; init; } + public List IpAllowlist { get; init; } = []; + } +} + +/// +/// Configuration options for webhook security. +/// +public sealed class WebhookSecurityOptions +{ + /// + /// Grace period during which both old and new secrets are valid after rotation. + /// + public TimeSpan SecretRotationGracePeriod { get; set; } = TimeSpan.FromHours(24); + + /// + /// Whether to enforce IP allowlists when configured. + /// + public bool EnforceIpAllowlist { get; set; } = true; + + /// + /// Timestamp tolerance for signature verification (in seconds). + /// + public int TimestampToleranceSeconds { get; set; } = 300; +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/HmacAckTokenService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/HmacAckTokenService.cs new file mode 100644 index 000000000..38c5261ed --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/HmacAckTokenService.cs @@ -0,0 +1,292 @@ +using System.Buffers.Text; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace StellaOps.Notifier.Worker.Security; + +/// +/// HMAC-SHA256 based implementation of acknowledgement token service. +/// +public sealed class HmacAckTokenService : IAckTokenService, IDisposable +{ + private const int CurrentVersion = 1; + private const string TokenPrefix = "soa1"; // StellaOps Ack v1 + + private readonly AckTokenOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly HMACSHA256 _hmac; + private bool _disposed; + + public HmacAckTokenService( + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + if (string.IsNullOrWhiteSpace(_options.SigningKey)) + { + throw new InvalidOperationException("AckTokenOptions.SigningKey must be configured."); + } + + // Derive key using HKDF for proper key derivation + var keyBytes = Encoding.UTF8.GetBytes(_options.SigningKey); + var derivedKey = HKDF.DeriveKey( + HashAlgorithmName.SHA256, + keyBytes, + 32, // 256 bits + info: Encoding.UTF8.GetBytes("StellaOps.AckToken.v1")); + + _hmac = new HMACSHA256(derivedKey); + } + + public AckToken CreateToken( + string tenantId, + string deliveryId, + string action, + TimeSpan? expiration = null, + IReadOnlyDictionary? metadata = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId); + ArgumentException.ThrowIfNullOrWhiteSpace(action); + + var tokenId = Guid.NewGuid().ToString("N"); + var now = _timeProvider.GetUtcNow(); + var expiresAt = now.Add(expiration ?? _options.DefaultExpiration); + + var payload = new AckTokenPayload + { + Version = CurrentVersion, + TokenId = tokenId, + TenantId = tenantId, + DeliveryId = deliveryId, + Action = action, + IssuedAt = now.ToUnixTimeSeconds(), + ExpiresAt = expiresAt.ToUnixTimeSeconds(), + Metadata = metadata?.ToDictionary(k => k.Key, k => k.Value) ?? new Dictionary() + }; + + var payloadJson = JsonSerializer.Serialize(payload, AckTokenJsonContext.Default.AckTokenPayload); + var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); + + // Sign the payload + var signature = _hmac.ComputeHash(payloadBytes); + + // Combine: prefix.payload.signature (all base64url) + var payloadB64 = Base64UrlEncode(payloadBytes); + var signatureB64 = Base64UrlEncode(signature); + var tokenString = $"{TokenPrefix}.{payloadB64}.{signatureB64}"; + + _logger.LogDebug( + "Created ack token {TokenId} for delivery {DeliveryId} expiring at {ExpiresAt}", + tokenId, deliveryId, expiresAt); + + return new AckToken + { + TokenId = tokenId, + TenantId = tenantId, + DeliveryId = deliveryId, + Action = action, + IssuedAt = now, + ExpiresAt = expiresAt, + Metadata = metadata?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + TokenString = tokenString + }; + } + + public AckTokenVerification VerifyToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Token is empty"); + } + + var parts = token.Split('.'); + if (parts.Length != 3) + { + return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid token structure"); + } + + var prefix = parts[0]; + var payloadB64 = parts[1]; + var signatureB64 = parts[2]; + + // Check version prefix + if (prefix != TokenPrefix) + { + return AckTokenVerification.Fail(AckTokenFailureReason.UnsupportedVersion, $"Unknown prefix: {prefix}"); + } + + // Decode payload + byte[] payloadBytes; + try + { + payloadBytes = Base64UrlDecode(payloadB64); + } + catch (FormatException) + { + return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid payload encoding"); + } + + // Verify signature + byte[] providedSignature; + try + { + providedSignature = Base64UrlDecode(signatureB64); + } + catch (FormatException) + { + return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid signature encoding"); + } + + var expectedSignature = _hmac.ComputeHash(payloadBytes); + if (!CryptographicOperations.FixedTimeEquals(expectedSignature, providedSignature)) + { + _logger.LogWarning("Invalid signature for ack token"); + return AckTokenVerification.Fail(AckTokenFailureReason.InvalidSignature); + } + + // Parse payload + AckTokenPayload payload; + try + { + payload = JsonSerializer.Deserialize(payloadBytes, AckTokenJsonContext.Default.AckTokenPayload) + ?? throw new JsonException("Null payload"); + } + catch (JsonException ex) + { + return AckTokenVerification.Fail(AckTokenFailureReason.MalformedPayload, ex.Message); + } + + // Check version + if (payload.Version != CurrentVersion) + { + return AckTokenVerification.Fail(AckTokenFailureReason.UnsupportedVersion, $"Version {payload.Version} not supported"); + } + + // Check expiration + var now = _timeProvider.GetUtcNow(); + var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAt); + if (now > expiresAt) + { + return AckTokenVerification.Fail(AckTokenFailureReason.Expired, $"Token expired at {expiresAt}"); + } + + var ackToken = new AckToken + { + TokenId = payload.TokenId, + TenantId = payload.TenantId, + DeliveryId = payload.DeliveryId, + Action = payload.Action, + IssuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAt), + ExpiresAt = expiresAt, + Metadata = payload.Metadata.ToImmutableDictionary(), + TokenString = token + }; + + return AckTokenVerification.Success(ackToken); + } + + public string CreateAckUrl(AckToken token) + { + ArgumentNullException.ThrowIfNull(token); + + if (string.IsNullOrWhiteSpace(_options.BaseUrl)) + { + throw new InvalidOperationException("AckTokenOptions.BaseUrl must be configured."); + } + + var baseUrl = _options.BaseUrl.TrimEnd('/'); + return $"{baseUrl}/api/v1/ack/{Uri.EscapeDataString(token.TokenString)}"; + } + + public void Dispose() + { + if (!_disposed) + { + _hmac.Dispose(); + _disposed = true; + } + } + + private static string Base64UrlEncode(byte[] data) + { + return Convert.ToBase64String(data) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } + + private static byte[] Base64UrlDecode(string input) + { + var padded = input + .Replace('-', '+') + .Replace('_', '/'); + + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + return Convert.FromBase64String(padded); + } + + /// + /// Internal payload structure for serialization. + /// + internal sealed class AckTokenPayload + { + public int Version { get; set; } + public string TokenId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string DeliveryId { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public long IssuedAt { get; set; } + public long ExpiresAt { get; set; } + public Dictionary Metadata { get; set; } = new(); + } +} + +/// +/// Configuration options for ack token service. +/// +public sealed class AckTokenOptions +{ + /// + /// The signing key for HMAC. Should be at least 32 characters. + /// In production, this should come from KMS/Key Vault. + /// + public string SigningKey { get; set; } = string.Empty; + + /// + /// Base URL for generating acknowledgement URLs. + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// Default token expiration if not specified. + /// + public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromDays(7); + + /// + /// Maximum allowed token expiration. + /// + public TimeSpan MaxExpiration { get; set; } = TimeSpan.FromDays(30); +} + +/// +/// JSON serialization context for AOT compatibility. +/// +[System.Text.Json.Serialization.JsonSerializable(typeof(HmacAckTokenService.AckTokenPayload))] +internal partial class AckTokenJsonContext : System.Text.Json.Serialization.JsonSerializerContext +{ +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IAckTokenService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IAckTokenService.cs new file mode 100644 index 000000000..fc31d8a5e --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IAckTokenService.cs @@ -0,0 +1,141 @@ +using System.Collections.Immutable; + +namespace StellaOps.Notifier.Worker.Security; + +/// +/// Service for creating and verifying signed acknowledgement tokens. +/// +public interface IAckTokenService +{ + /// + /// Creates a signed acknowledgement token for a notification. + /// + /// The tenant ID. + /// The delivery ID being acknowledged. + /// The action being acknowledged (e.g., "ack", "resolve", "escalate"). + /// Optional expiration time. Defaults to 7 days. + /// Optional metadata to embed in the token. + /// The signed token. + AckToken CreateToken( + string tenantId, + string deliveryId, + string action, + TimeSpan? expiration = null, + IReadOnlyDictionary? metadata = null); + + /// + /// Verifies a signed acknowledgement token. + /// + /// The token string to verify. + /// The verification result. + AckTokenVerification VerifyToken(string token); + + /// + /// Creates a full acknowledgement URL with the signed token. + /// + /// The token to embed. + /// The full URL. + string CreateAckUrl(AckToken token); +} + +/// +/// A signed acknowledgement token. +/// +public sealed record AckToken +{ + /// + /// The unique token identifier. + /// + public required string TokenId { get; init; } + + /// + /// The tenant ID. + /// + public required string TenantId { get; init; } + + /// + /// The delivery ID being acknowledged. + /// + public required string DeliveryId { get; init; } + + /// + /// The action being acknowledged. + /// + public required string Action { get; init; } + + /// + /// When the token was issued. + /// + public required DateTimeOffset IssuedAt { get; init; } + + /// + /// When the token expires. + /// + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Optional embedded metadata. + /// + public ImmutableDictionary Metadata { get; init; } = ImmutableDictionary.Empty; + + /// + /// The signed token string (base64url encoded). + /// + public required string TokenString { get; init; } +} + +/// +/// Result of token verification. +/// +public sealed record AckTokenVerification +{ + /// + /// Whether the token is valid. + /// + public required bool IsValid { get; init; } + + /// + /// The parsed token if valid, null otherwise. + /// + public AckToken? Token { get; init; } + + /// + /// The failure reason if invalid. + /// + public AckTokenFailureReason? FailureReason { get; init; } + + /// + /// Additional failure details. + /// + public string? FailureDetails { get; init; } + + public static AckTokenVerification Success(AckToken token) + => new() { IsValid = true, Token = token }; + + public static AckTokenVerification Fail(AckTokenFailureReason reason, string? details = null) + => new() { IsValid = false, FailureReason = reason, FailureDetails = details }; +} + +/// +/// Reasons for token verification failure. +/// +public enum AckTokenFailureReason +{ + /// Token format is invalid. + InvalidFormat, + + /// Token signature is invalid. + InvalidSignature, + + /// Token has expired. + Expired, + + /// Token has been revoked. + Revoked, + + /// Token payload is malformed. + MalformedPayload, + + /// Token version is unsupported. + UnsupportedVersion +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IHtmlSanitizer.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IHtmlSanitizer.cs new file mode 100644 index 000000000..268c6d7d0 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IHtmlSanitizer.cs @@ -0,0 +1,177 @@ +namespace StellaOps.Notifier.Worker.Security; + +/// +/// Service for sanitizing HTML content in notification templates. +/// +public interface IHtmlSanitizer +{ + /// + /// Sanitizes HTML content, removing potentially dangerous elements and attributes. + /// + /// The HTML content to sanitize. + /// Optional sanitization options. + /// The sanitized HTML. + string Sanitize(string html, HtmlSanitizeOptions? options = null); + + /// + /// Validates HTML content and returns any security issues found. + /// + /// The HTML content to validate. + /// Validation result with any issues found. + HtmlValidationResult Validate(string html); + + /// + /// Strips all HTML tags, leaving only text content. + /// + /// The HTML content. + /// Plain text content. + string StripHtml(string html); +} + +/// +/// Options for HTML sanitization. +/// +public sealed class HtmlSanitizeOptions +{ + /// + /// Additional tags to allow beyond the default set. + /// + public IReadOnlySet? AdditionalAllowedTags { get; init; } + + /// + /// Additional attributes to allow beyond the default set. + /// + public IReadOnlySet? AdditionalAllowedAttributes { get; init; } + + /// + /// Whether to allow data: URLs in src attributes. Default: false. + /// + public bool AllowDataUrls { get; init; } + + /// + /// Whether to allow external URLs. Default: true. + /// + public bool AllowExternalUrls { get; init; } = true; + + /// + /// Maximum allowed depth of nested elements. Default: 50. + /// + public int MaxNestingDepth { get; init; } = 50; + + /// + /// Maximum content length. Default: 1MB. + /// + public int MaxContentLength { get; init; } = 1024 * 1024; +} + +/// +/// Result of HTML validation. +/// +public sealed record HtmlValidationResult +{ + /// + /// Whether the HTML is safe. + /// + public required bool IsSafe { get; init; } + + /// + /// List of security issues found. + /// + public required IReadOnlyList Issues { get; init; } + + /// + /// Statistics about the HTML content. + /// + public HtmlContentStats? Stats { get; init; } + + public static HtmlValidationResult Safe(HtmlContentStats? stats = null) + => new() { IsSafe = true, Issues = [], Stats = stats }; + + public static HtmlValidationResult Unsafe(IReadOnlyList issues, HtmlContentStats? stats = null) + => new() { IsSafe = false, Issues = issues, Stats = stats }; +} + +/// +/// A security issue found in HTML content. +/// +public sealed record HtmlSecurityIssue +{ + /// + /// The type of security issue. + /// + public required HtmlSecurityIssueType Type { get; init; } + + /// + /// Description of the issue. + /// + public required string Description { get; init; } + + /// + /// The problematic element or attribute name. + /// + public string? ElementName { get; init; } + + /// + /// The problematic attribute name. + /// + public string? AttributeName { get; init; } + + /// + /// Approximate location in the content. + /// + public int? Position { get; init; } +} + +/// +/// Types of HTML security issues. +/// +public enum HtmlSecurityIssueType +{ + /// Script element or inline script. + ScriptInjection, + + /// Event handler attribute (onclick, onerror, etc.). + EventHandler, + + /// Dangerous URL scheme (javascript:, data:, etc.). + DangerousUrl, + + /// Potentially dangerous element (iframe, object, embed, etc.). + DangerousElement, + + /// Style-based attack (expression, behavior, etc.). + StyleInjection, + + /// Form-based attack (action hijacking). + FormHijacking, + + /// Content exceeds size limits. + ContentTooLarge, + + /// Excessive nesting depth. + ExcessiveNesting, + + /// Malformed HTML that could be used to bypass filters. + MalformedHtml +} + +/// +/// Statistics about HTML content. +/// +public sealed record HtmlContentStats +{ + /// Total character count. + public int CharacterCount { get; init; } + + /// Number of HTML elements. + public int ElementCount { get; init; } + + /// Maximum nesting depth. + public int MaxDepth { get; init; } + + /// Number of links. + public int LinkCount { get; init; } + + /// Number of images. + public int ImageCount { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/ITenantIsolationValidator.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/ITenantIsolationValidator.cs new file mode 100644 index 000000000..d2535e421 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/ITenantIsolationValidator.cs @@ -0,0 +1,190 @@ +namespace StellaOps.Notifier.Worker.Security; + +/// +/// Service for validating tenant isolation across operations. +/// +public interface ITenantIsolationValidator +{ + /// + /// Validates that a resource belongs to the specified tenant. + /// + /// The tenant ID from the request. + /// The tenant ID of the resource being accessed. + /// The type of resource being accessed. + /// The ID of the resource being accessed. + /// Validation result. + TenantIsolationResult ValidateAccess( + string requestTenantId, + string resourceTenantId, + string resourceType, + string resourceId); + + /// + /// Validates a batch of resources belong to the specified tenant. + /// + /// The tenant ID from the request. + /// The resources to validate. + /// Validation result for each resource. + IReadOnlyList ValidateBatch( + string requestTenantId, + IEnumerable resources); + + /// + /// Sanitizes a tenant ID for safe use. + /// + /// The tenant ID to sanitize. + /// The sanitized tenant ID or null if invalid. + string? SanitizeTenantId(string? tenantId); + + /// + /// Validates tenant ID format. + /// + /// The tenant ID to validate. + /// True if valid format. + bool IsValidTenantIdFormat(string? tenantId); + + /// + /// Registers a tenant isolation violation for monitoring. + /// + /// The violation details. + void RecordViolation(TenantIsolationViolation violation); + + /// + /// Gets recent violations for monitoring purposes. + /// + /// Maximum number of violations to return. + /// Recent violations. + IReadOnlyList GetRecentViolations(int limit = 100); +} + +/// +/// A resource with tenant information. +/// +public sealed record TenantResource +{ + /// + /// The tenant ID of the resource. + /// + public required string TenantId { get; init; } + + /// + /// The type of resource. + /// + public required string ResourceType { get; init; } + + /// + /// The resource ID. + /// + public required string ResourceId { get; init; } +} + +/// +/// Result of tenant isolation validation. +/// +public sealed record TenantIsolationResult +{ + /// + /// Whether access is allowed. + /// + public required bool IsAllowed { get; init; } + + /// + /// The request tenant ID. + /// + public required string RequestTenantId { get; init; } + + /// + /// The resource tenant ID. + /// + public required string ResourceTenantId { get; init; } + + /// + /// The resource type. + /// + public string? ResourceType { get; init; } + + /// + /// The resource ID. + /// + public string? ResourceId { get; init; } + + /// + /// Rejection reason if not allowed. + /// + public string? RejectionReason { get; init; } + + public static TenantIsolationResult Allow(string requestTenantId, string resourceTenantId) + => new() + { + IsAllowed = true, + RequestTenantId = requestTenantId, + ResourceTenantId = resourceTenantId + }; + + public static TenantIsolationResult Deny( + string requestTenantId, + string resourceTenantId, + string reason, + string? resourceType = null, + string? resourceId = null) + => new() + { + IsAllowed = false, + RequestTenantId = requestTenantId, + ResourceTenantId = resourceTenantId, + RejectionReason = reason, + ResourceType = resourceType, + ResourceId = resourceId + }; +} + +/// +/// Record of a tenant isolation violation. +/// +public sealed record TenantIsolationViolation +{ + /// + /// When the violation occurred. + /// + public required DateTimeOffset OccurredAt { get; init; } + + /// + /// The request tenant ID. + /// + public required string RequestTenantId { get; init; } + + /// + /// The resource tenant ID. + /// + public required string ResourceTenantId { get; init; } + + /// + /// The type of resource accessed. + /// + public required string ResourceType { get; init; } + + /// + /// The resource ID accessed. + /// + public required string ResourceId { get; init; } + + /// + /// The operation being performed. + /// + public string? Operation { get; init; } + + /// + /// Source IP address of the request. + /// + public string? SourceIp { get; init; } + + /// + /// User agent of the request. + /// + public string? UserAgent { get; init; } + + /// + /// Additional context about the violation. + /// + public IReadOnlyDictionary? Context { get; init; } +} diff --git a/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IWebhookSecurityService.cs b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IWebhookSecurityService.cs new file mode 100644 index 000000000..a7b102158 --- /dev/null +++ b/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/Security/IWebhookSecurityService.cs @@ -0,0 +1,147 @@ +using System.Net; + +namespace StellaOps.Notifier.Worker.Security; + +/// +/// Service for webhook security including HMAC signing and IP validation. +/// +public interface IWebhookSecurityService +{ + /// + /// Signs a webhook payload and returns the signature header value. + /// + /// The tenant ID. + /// The channel ID. + /// The payload bytes to sign. + /// The timestamp to include in signature. + /// The signature header value. + string SignPayload(string tenantId, string channelId, ReadOnlySpan payload, DateTimeOffset timestamp); + + /// + /// Verifies an incoming webhook callback signature. + /// + /// The tenant ID. + /// The channel ID. + /// The payload bytes. + /// The signature header value. + /// True if signature is valid. + bool VerifySignature(string tenantId, string channelId, ReadOnlySpan payload, string signatureHeader); + + /// + /// Validates if an IP address is allowed for a channel. + /// + /// The tenant ID. + /// The channel ID. + /// The IP address to check. + /// Validation result. + IpValidationResult ValidateIp(string tenantId, string channelId, IPAddress ipAddress); + + /// + /// Gets the current webhook secret for a channel (for configuration display). + /// + /// The tenant ID. + /// The channel ID. + /// A masked version of the secret. + string GetMaskedSecret(string tenantId, string channelId); + + /// + /// Rotates the webhook secret for a channel. + /// + /// The tenant ID. + /// The channel ID. + /// Cancellation token. + /// The new secret. + Task RotateSecretAsync( + string tenantId, + string channelId, + CancellationToken cancellationToken = default); +} + +/// +/// Result of IP validation. +/// +public sealed record IpValidationResult +{ + /// + /// Whether the IP is allowed. + /// + public required bool IsAllowed { get; init; } + + /// + /// The reason for rejection if not allowed. + /// + public string? RejectionReason { get; init; } + + /// + /// The matched allowlist entry if allowed. + /// + public string? MatchedEntry { get; init; } + + /// + /// Whether an allowlist is configured for this channel. + /// + public bool HasAllowlist { get; init; } + + public static IpValidationResult Allow(string? matchedEntry = null, bool hasAllowlist = false) + => new() { IsAllowed = true, MatchedEntry = matchedEntry, HasAllowlist = hasAllowlist }; + + public static IpValidationResult Deny(string reason, bool hasAllowlist = true) + => new() { IsAllowed = false, RejectionReason = reason, HasAllowlist = hasAllowlist }; +} + +/// +/// Result of secret rotation. +/// +public sealed record WebhookSecretRotationResult +{ + /// + /// Whether rotation succeeded. + /// + public required bool Success { get; init; } + + /// + /// The new secret (only available immediately after rotation). + /// + public string? NewSecret { get; init; } + + /// + /// Error message if rotation failed. + /// + public string? Error { get; init; } + + /// + /// When the new secret becomes active. + /// + public DateTimeOffset? ActiveAt { get; init; } + + /// + /// When the old secret expires. + /// + public DateTimeOffset? OldSecretExpiresAt { get; init; } +} + +/// +/// Configuration for an IP allowlist entry. +/// +public sealed record IpAllowlistEntry +{ + /// + /// The CIDR notation or single IP address. + /// + public required string CidrOrIp { get; init; } + + /// + /// Optional description for this entry. + /// + public string? Description { get; init; } + + /// + /// When this entry was added. + /// + public DateTimeOffset AddedAt { get; init; } + + /// + /// Who added this entry. + /// + public string? AddedBy { get; init; } +} diff --git a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyChannelRepository.cs b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyChannelRepository.cs index 52f9f8353..86ad5e5aa 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyChannelRepository.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyChannelRepository.cs @@ -4,14 +4,16 @@ using MongoDB.Driver; using StellaOps.Notify.Models; using StellaOps.Notify.Storage.Mongo.Internal; using StellaOps.Notify.Storage.Mongo.Serialization; +using StellaOps.Notify.Storage.Mongo.Tenancy; namespace StellaOps.Notify.Storage.Mongo.Repositories; internal sealed class NotifyChannelRepository : INotifyChannelRepository { private readonly IMongoCollection _collection; + private readonly ITenantContext _tenantContext; - public NotifyChannelRepository(NotifyMongoContext context) + public NotifyChannelRepository(NotifyMongoContext context, ITenantContext? tenantContext = null) { if (context is null) { @@ -19,23 +21,34 @@ internal sealed class NotifyChannelRepository : INotifyChannelRepository } _collection = context.Database.GetCollection(context.Options.ChannelsCollection); + _tenantContext = tenantContext ?? NullTenantContext.Instance; } public async Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(channel); + _tenantContext.ValidateTenant(channel.TenantId); + var document = NotifyChannelDocumentMapper.ToBsonDocument(channel); - var filter = Builders.Filter.Eq("_id", CreateDocumentId(channel.TenantId, channel.ChannelId)); + // RLS: Dual-filter with both ID and tenantId for defense-in-depth + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(channel.TenantId, channel.ChannelId)), + Builders.Filter.Eq("tenantId", channel.TenantId)); await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); } public async Task GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("_id", CreateDocumentId(tenantId, channelId)) - & Builders.Filter.Or( + _tenantContext.ValidateTenant(tenantId); + + // RLS: Dual-filter with both ID and explicit tenantId check + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)), + Builders.Filter.Eq("tenantId", tenantId), + Builders.Filter.Or( Builders.Filter.Exists("deletedAt", false), - Builders.Filter.Eq("deletedAt", BsonNull.Value)); + Builders.Filter.Eq("deletedAt", BsonNull.Value))); var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); return document is null ? null : NotifyChannelDocumentMapper.FromBsonDocument(document); @@ -43,28 +56,30 @@ internal sealed class NotifyChannelRepository : INotifyChannelRepository public async Task> ListAsync(string tenantId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("tenantId", tenantId) - & Builders.Filter.Or( + _tenantContext.ValidateTenant(tenantId); + + var filter = Builders.Filter.And( + Builders.Filter.Eq("tenantId", tenantId), + Builders.Filter.Or( Builders.Filter.Exists("deletedAt", false), - Builders.Filter.Eq("deletedAt", BsonNull.Value)); + Builders.Filter.Eq("deletedAt", BsonNull.Value))); + var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false); return cursor.Select(NotifyChannelDocumentMapper.FromBsonDocument).ToArray(); } public async Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("_id", CreateDocumentId(tenantId, channelId)); + _tenantContext.ValidateTenant(tenantId); + + // RLS: Dual-filter with both ID and tenantId for defense-in-depth + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)), + Builders.Filter.Eq("tenantId", tenantId)); + await _collection.UpdateOneAsync(filter, Builders.Update.Set("deletedAt", DateTime.UtcNow).Set("enabled", false), new UpdateOptions { IsUpsert = false }, cancellationToken).ConfigureAwait(false); } - - private static string CreateDocumentId(string tenantId, string resourceId) - => string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) => - { - value.tenantId.AsSpan().CopyTo(span); - span[value.tenantId.Length] = ':'; - value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]); - }); } diff --git a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyRuleRepository.cs b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyRuleRepository.cs index 9ffae84e4..c519cf3da 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyRuleRepository.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyRuleRepository.cs @@ -5,14 +5,16 @@ using MongoDB.Driver; using StellaOps.Notify.Models; using StellaOps.Notify.Storage.Mongo.Internal; using StellaOps.Notify.Storage.Mongo.Serialization; +using StellaOps.Notify.Storage.Mongo.Tenancy; namespace StellaOps.Notify.Storage.Mongo.Repositories; internal sealed class NotifyRuleRepository : INotifyRuleRepository { private readonly IMongoCollection _collection; + private readonly ITenantContext _tenantContext; - public NotifyRuleRepository(NotifyMongoContext context) + public NotifyRuleRepository(NotifyMongoContext context, ITenantContext? tenantContext = null) { if (context is null) { @@ -20,23 +22,34 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository } _collection = context.Database.GetCollection(context.Options.RulesCollection); + _tenantContext = tenantContext ?? NullTenantContext.Instance; } public async Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(rule); + _tenantContext.ValidateTenant(rule.TenantId); + var document = NotifyRuleDocumentMapper.ToBsonDocument(rule); - var filter = Builders.Filter.Eq("_id", CreateDocumentId(rule.TenantId, rule.RuleId)); + // RLS: Dual-filter with both ID and tenantId for defense-in-depth + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(rule.TenantId, rule.RuleId)), + Builders.Filter.Eq("tenantId", rule.TenantId)); await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); } public async Task GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId)) - & Builders.Filter.Or( + _tenantContext.ValidateTenant(tenantId); + + // RLS: Dual-filter with both ID and explicit tenantId check + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)), + Builders.Filter.Eq("tenantId", tenantId), + Builders.Filter.Or( Builders.Filter.Exists("deletedAt", false), - Builders.Filter.Eq("deletedAt", BsonNull.Value)); + Builders.Filter.Eq("deletedAt", BsonNull.Value))); var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); return document is null ? null : NotifyRuleDocumentMapper.FromBsonDocument(document); @@ -44,17 +57,27 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository public async Task> ListAsync(string tenantId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("tenantId", tenantId) - & Builders.Filter.Or( + _tenantContext.ValidateTenant(tenantId); + + var filter = Builders.Filter.And( + Builders.Filter.Eq("tenantId", tenantId), + Builders.Filter.Or( Builders.Filter.Exists("deletedAt", false), - Builders.Filter.Eq("deletedAt", BsonNull.Value)); + Builders.Filter.Eq("deletedAt", BsonNull.Value))); + var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false); return cursor.Select(NotifyRuleDocumentMapper.FromBsonDocument).ToArray(); } public async Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId)); + _tenantContext.ValidateTenant(tenantId); + + // RLS: Dual-filter with both ID and tenantId for defense-in-depth + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)), + Builders.Filter.Eq("tenantId", tenantId)); + await _collection.UpdateOneAsync(filter, Builders.Update .Set("deletedAt", DateTime.UtcNow) @@ -62,12 +85,4 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository new UpdateOptions { IsUpsert = false }, cancellationToken).ConfigureAwait(false); } - - private static string CreateDocumentId(string tenantId, string resourceId) - => string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) => - { - value.tenantId.AsSpan().CopyTo(span); - span[value.tenantId.Length] = ':'; - value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]); - }); } diff --git a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyTemplateRepository.cs b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyTemplateRepository.cs index 97af96113..30e301f68 100644 --- a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyTemplateRepository.cs +++ b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Repositories/NotifyTemplateRepository.cs @@ -4,14 +4,16 @@ using MongoDB.Driver; using StellaOps.Notify.Models; using StellaOps.Notify.Storage.Mongo.Internal; using StellaOps.Notify.Storage.Mongo.Serialization; +using StellaOps.Notify.Storage.Mongo.Tenancy; namespace StellaOps.Notify.Storage.Mongo.Repositories; internal sealed class NotifyTemplateRepository : INotifyTemplateRepository { private readonly IMongoCollection _collection; + private readonly ITenantContext _tenantContext; - public NotifyTemplateRepository(NotifyMongoContext context) + public NotifyTemplateRepository(NotifyMongoContext context, ITenantContext? tenantContext = null) { if (context is null) { @@ -19,23 +21,34 @@ internal sealed class NotifyTemplateRepository : INotifyTemplateRepository } _collection = context.Database.GetCollection(context.Options.TemplatesCollection); + _tenantContext = tenantContext ?? NullTenantContext.Instance; } public async Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(template); + _tenantContext.ValidateTenant(template.TenantId); + var document = NotifyTemplateDocumentMapper.ToBsonDocument(template); - var filter = Builders.Filter.Eq("_id", CreateDocumentId(template.TenantId, template.TemplateId)); + // RLS: Dual-filter with both ID and tenantId for defense-in-depth + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(template.TenantId, template.TemplateId)), + Builders.Filter.Eq("tenantId", template.TenantId)); await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); } public async Task GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("_id", CreateDocumentId(tenantId, templateId)) - & Builders.Filter.Or( + _tenantContext.ValidateTenant(tenantId); + + // RLS: Dual-filter with both ID and explicit tenantId check + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)), + Builders.Filter.Eq("tenantId", tenantId), + Builders.Filter.Or( Builders.Filter.Exists("deletedAt", false), - Builders.Filter.Eq("deletedAt", BsonNull.Value)); + Builders.Filter.Eq("deletedAt", BsonNull.Value))); var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); return document is null ? null : NotifyTemplateDocumentMapper.FromBsonDocument(document); @@ -43,28 +56,30 @@ internal sealed class NotifyTemplateRepository : INotifyTemplateRepository public async Task> ListAsync(string tenantId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("tenantId", tenantId) - & Builders.Filter.Or( + _tenantContext.ValidateTenant(tenantId); + + var filter = Builders.Filter.And( + Builders.Filter.Eq("tenantId", tenantId), + Builders.Filter.Or( Builders.Filter.Exists("deletedAt", false), - Builders.Filter.Eq("deletedAt", BsonNull.Value)); + Builders.Filter.Eq("deletedAt", BsonNull.Value))); + var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false); return cursor.Select(NotifyTemplateDocumentMapper.FromBsonDocument).ToArray(); } public async Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default) { - var filter = Builders.Filter.Eq("_id", CreateDocumentId(tenantId, templateId)); + _tenantContext.ValidateTenant(tenantId); + + // RLS: Dual-filter with both ID and tenantId for defense-in-depth + var filter = Builders.Filter.And( + Builders.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)), + Builders.Filter.Eq("tenantId", tenantId)); + await _collection.UpdateOneAsync(filter, Builders.Update.Set("deletedAt", DateTime.UtcNow), new UpdateOptions { IsUpsert = false }, cancellationToken).ConfigureAwait(false); } - - private static string CreateDocumentId(string tenantId, string resourceId) - => string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) => - { - value.tenantId.AsSpan().CopyTo(span); - span[value.tenantId.Length] = ':'; - value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]); - }); } diff --git a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/ITenantContext.cs b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/ITenantContext.cs new file mode 100644 index 000000000..6f1ab4029 --- /dev/null +++ b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/ITenantContext.cs @@ -0,0 +1,145 @@ +namespace StellaOps.Notify.Storage.Mongo.Tenancy; + +/// +/// Provides tenant context for RLS-like tenant isolation in storage operations. +/// +public interface ITenantContext +{ + /// + /// Gets the current authenticated tenant ID, or null if not authenticated. + /// + string? CurrentTenantId { get; } + + /// + /// Returns true if the current context has a valid tenant. + /// + bool HasTenant { get; } + + /// + /// Validates that the requested tenant matches the current context. + /// Throws if validation fails. + /// + /// The tenant ID being requested. + /// Thrown when tenants don't match. + void ValidateTenant(string requestedTenantId); + + /// + /// Returns true if the current context allows access to the specified tenant. + /// Admin tenants may access other tenants. + /// + bool CanAccessTenant(string targetTenantId); +} + +/// +/// Exception thrown when a tenant isolation violation is detected. +/// +public sealed class TenantMismatchException : InvalidOperationException +{ + public string RequestedTenantId { get; } + public string? CurrentTenantId { get; } + + public TenantMismatchException(string requestedTenantId, string? currentTenantId) + : base($"Tenant isolation violation: requested tenant '{requestedTenantId}' does not match current tenant '{currentTenantId ?? "(none)"}'") + { + RequestedTenantId = requestedTenantId; + CurrentTenantId = currentTenantId; + } +} + +/// +/// Default implementation that uses AsyncLocal to track tenant context. +/// +public sealed class DefaultTenantContext : ITenantContext +{ + private static readonly AsyncLocal _currentTenant = new(); + private readonly HashSet _adminTenants; + + public DefaultTenantContext(IEnumerable? adminTenants = null) + { + _adminTenants = adminTenants?.ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new HashSet(StringComparer.OrdinalIgnoreCase) { "admin", "system" }; + } + + public string? CurrentTenantId + { + get => _currentTenant.Value; + set => _currentTenant.Value = value; + } + + public bool HasTenant => !string.IsNullOrWhiteSpace(_currentTenant.Value); + + public void ValidateTenant(string requestedTenantId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requestedTenantId); + + if (!CanAccessTenant(requestedTenantId)) + { + throw new TenantMismatchException(requestedTenantId, CurrentTenantId); + } + } + + public bool CanAccessTenant(string targetTenantId) + { + if (string.IsNullOrWhiteSpace(targetTenantId)) + return false; + + // No current tenant means no access + if (!HasTenant) + return false; + + // Same tenant always allowed + if (string.Equals(CurrentTenantId, targetTenantId, StringComparison.OrdinalIgnoreCase)) + return true; + + // Admin tenants can access other tenants + if (_adminTenants.Contains(CurrentTenantId!)) + return true; + + return false; + } + + /// + /// Sets the current tenant context. Returns a disposable to restore previous value. + /// + public IDisposable SetTenant(string tenantId) + { + var previous = _currentTenant.Value; + _currentTenant.Value = tenantId; + return new TenantScope(previous); + } + + private sealed class TenantScope : IDisposable + { + private readonly string? _previousTenant; + private bool _disposed; + + public TenantScope(string? previousTenant) => _previousTenant = previousTenant; + + public void Dispose() + { + if (!_disposed) + { + _currentTenant.Value = _previousTenant; + _disposed = true; + } + } + } +} + +/// +/// Null implementation for testing or contexts without tenant isolation. +/// +public sealed class NullTenantContext : ITenantContext +{ + public static readonly NullTenantContext Instance = new(); + + public string? CurrentTenantId => null; + public bool HasTenant => false; + + public void ValidateTenant(string requestedTenantId) + { + // No-op - allows all access + } + + public bool CanAccessTenant(string targetTenantId) => true; +} diff --git a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantAwareRepository.cs b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantAwareRepository.cs new file mode 100644 index 000000000..28e55b6de --- /dev/null +++ b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantAwareRepository.cs @@ -0,0 +1,109 @@ +using MongoDB.Bson; +using MongoDB.Driver; + +namespace StellaOps.Notify.Storage.Mongo.Tenancy; + +/// +/// Base class for tenant-aware MongoDB repositories with RLS-like filtering. +/// +public abstract class TenantAwareRepository +{ + private readonly ITenantContext _tenantContext; + + protected TenantAwareRepository(ITenantContext? tenantContext = null) + { + _tenantContext = tenantContext ?? NullTenantContext.Instance; + } + + /// + /// Gets the tenant context for validation. + /// + protected ITenantContext TenantContext => _tenantContext; + + /// + /// Validates that the requested tenant is accessible from the current context. + /// + /// The tenant ID being requested. + protected void ValidateTenantAccess(string requestedTenantId) + { + _tenantContext.ValidateTenant(requestedTenantId); + } + + /// + /// Creates a filter that includes both ID and explicit tenantId check (dual-filter pattern). + /// This provides RLS-like defense-in-depth. + /// + /// The tenant ID. + /// The full document ID (typically tenant-scoped). + /// A filter requiring both ID match and tenantId match. + protected static FilterDefinition CreateTenantSafeIdFilter( + string tenantId, + string documentId) + { + return Builders.Filter.And( + Builders.Filter.Eq("_id", documentId), + Builders.Filter.Eq("tenantId", tenantId) + ); + } + + /// + /// Wraps a filter with an explicit tenantId check. + /// + /// The tenant ID to scope the query to. + /// The base filter to wrap. + /// A filter that includes the tenantId check. + protected static FilterDefinition WithTenantScope( + string tenantId, + FilterDefinition baseFilter) + { + return Builders.Filter.And( + Builders.Filter.Eq("tenantId", tenantId), + baseFilter + ); + } + + /// + /// Creates a filter for listing documents within a tenant. + /// + /// The tenant ID. + /// Whether to include soft-deleted documents. + /// A filter for the tenant's documents. + protected static FilterDefinition CreateTenantListFilter( + string tenantId, + bool includeDeleted = false) + { + var filter = Builders.Filter.Eq("tenantId", tenantId); + + if (!includeDeleted) + { + filter = Builders.Filter.And( + filter, + Builders.Filter.Or( + Builders.Filter.Exists("deletedAt", false), + Builders.Filter.Eq("deletedAt", BsonNull.Value) + ) + ); + } + + return filter; + } + + /// + /// Creates a sort definition for common ordering patterns. + /// + /// The field to sort by. + /// True for ascending, false for descending. + /// A sort definition. + protected static SortDefinition CreateSort(string sortBy, bool ascending = true) + { + return ascending + ? Builders.Sort.Ascending(sortBy) + : Builders.Sort.Descending(sortBy); + } + + /// + /// Creates a document ID using the tenant-scoped format. + /// + protected static string CreateDocumentId(string tenantId, string resourceId) + => TenantScopedId.Create(tenantId, resourceId); +} diff --git a/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantScopedId.cs b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantScopedId.cs new file mode 100644 index 000000000..2a537409f --- /dev/null +++ b/src/Notify/__Libraries/StellaOps.Notify.Storage.Mongo/Tenancy/TenantScopedId.cs @@ -0,0 +1,86 @@ +namespace StellaOps.Notify.Storage.Mongo.Tenancy; + +/// +/// Helper for constructing tenant-scoped document IDs with consistent format. +/// +public static class TenantScopedId +{ + private const char Separator = ':'; + + /// + /// Creates a tenant-scoped ID in the format "{tenantId}:{resourceId}". + /// + /// The tenant ID (required). + /// The resource ID (required). + /// A composite ID string. + /// Thrown if either parameter is null or whitespace. + public static string Create(string tenantId, string resourceId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceId); + + // Validate no separator in tenant or resource IDs to prevent injection + if (tenantId.Contains(Separator)) + throw new ArgumentException($"Tenant ID cannot contain '{Separator}'", nameof(tenantId)); + + if (resourceId.Contains(Separator)) + throw new ArgumentException($"Resource ID cannot contain '{Separator}'", nameof(resourceId)); + + return string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) => + { + value.tenantId.AsSpan().CopyTo(span); + span[value.tenantId.Length] = Separator; + value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]); + }); + } + + /// + /// Parses a tenant-scoped ID into its components. + /// + /// The composite ID to parse. + /// Output: the extracted tenant ID. + /// Output: the extracted resource ID. + /// True if parsing succeeded, false otherwise. + public static bool TryParse(string scopedId, out string tenantId, out string resourceId) + { + tenantId = string.Empty; + resourceId = string.Empty; + + if (string.IsNullOrWhiteSpace(scopedId)) + return false; + + var separatorIndex = scopedId.IndexOf(Separator); + if (separatorIndex <= 0 || separatorIndex >= scopedId.Length - 1) + return false; + + tenantId = scopedId[..separatorIndex]; + resourceId = scopedId[(separatorIndex + 1)..]; + + return !string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(resourceId); + } + + /// + /// Extracts the tenant ID from a tenant-scoped ID. + /// + /// The composite ID. + /// The tenant ID, or null if parsing failed. + public static string? ExtractTenantId(string scopedId) + { + return TryParse(scopedId, out var tenantId, out _) ? tenantId : null; + } + + /// + /// Validates that a scoped ID belongs to the expected tenant. + /// + /// The composite ID to validate. + /// The expected tenant ID. + /// True if the ID belongs to the expected tenant. + public static bool BelongsToTenant(string scopedId, string expectedTenantId) + { + if (string.IsNullOrWhiteSpace(scopedId) || string.IsNullOrWhiteSpace(expectedTenantId)) + return false; + + var extractedTenant = ExtractTenantId(scopedId); + return string.Equals(extractedTenant, expectedTenantId, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Scanner/StellaOps.Scanner.Worker/Determinism/DeterminismContext.cs b/src/Scanner/StellaOps.Scanner.Worker/Determinism/DeterminismContext.cs index a034acc00..b3a72e358 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Determinism/DeterminismContext.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Determinism/DeterminismContext.cs @@ -7,12 +7,13 @@ namespace StellaOps.Scanner.Worker.Determinism; /// public sealed class DeterminismContext { - public DeterminismContext(bool fixedClock, DateTimeOffset fixedInstantUtc, int? rngSeed, bool filterLogs) + public DeterminismContext(bool fixedClock, DateTimeOffset fixedInstantUtc, int? rngSeed, bool filterLogs, int? concurrencyLimit) { FixedClock = fixedClock; FixedInstantUtc = fixedInstantUtc.ToUniversalTime(); RngSeed = rngSeed; FilterLogs = filterLogs; + ConcurrencyLimit = concurrencyLimit; } public bool FixedClock { get; } @@ -22,4 +23,6 @@ public sealed class DeterminismContext public int? RngSeed { get; } public bool FilterLogs { get; } + + public int? ConcurrencyLimit { get; } } diff --git a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs index 5ef09dac2..2d87a9c80 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Processing/Surface/SurfaceManifestStageExecutor.cs @@ -42,6 +42,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor private readonly ILogger _logger; private readonly ICryptoHash _hash; private readonly IRubyPackageInventoryStore _rubyPackageStore; + private readonly Determinism.DeterminismContext _determinism; private readonly string _componentVersion; public SurfaceManifestStageExecutor( @@ -51,7 +52,8 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor ScannerWorkerMetrics metrics, ILogger logger, ICryptoHash hash, - IRubyPackageInventoryStore rubyPackageStore) + IRubyPackageInventoryStore rubyPackageStore, + Determinism.DeterminismContext determinism) { _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); _surfaceCache = surfaceCache ?? throw new ArgumentNullException(nameof(surfaceCache)); @@ -60,6 +62,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _hash = hash ?? throw new ArgumentNullException(nameof(hash)); _rubyPackageStore = rubyPackageStore ?? throw new ArgumentNullException(nameof(rubyPackageStore)); + _determinism = determinism ?? throw new ArgumentNullException(nameof(determinism)); _componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; } @@ -221,9 +224,56 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor })); } + var determinismPayload = BuildDeterminismPayload(context, payloads); + if (determinismPayload is not null) + { + payloads.Add(determinismPayload); + } + return payloads; } + private SurfaceManifestPayload? BuildDeterminismPayload(ScanJobContext context, IEnumerable payloads) + { + var pins = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (context.Lease.Metadata.TryGetValue("determinism.feed", out var feed) && !string.IsNullOrWhiteSpace(feed)) + { + pins["feed"] = feed; + } + + if (context.Lease.Metadata.TryGetValue("determinism.policy", out var policy) && !string.IsNullOrWhiteSpace(policy)) + { + pins["policy"] = policy; + } + + var artifactHashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var payload in payloads) + { + var digest = ComputeDigest(payload.Content.Span); + artifactHashes[payload.Kind] = digest; + } + + var report = new + { + fixedClock = _determinism.FixedClock, + fixedInstantUtc = _determinism.FixedInstantUtc, + rngSeed = _determinism.RngSeed, + filterLogs = _determinism.FilterLogs, + concurrencyLimit = _determinism.ConcurrencyLimit, + pins = pins, + artifacts = artifactHashes + }; + + var json = JsonSerializer.Serialize(report, JsonOptions); + return new SurfaceManifestPayload( + ArtifactDocumentType.SurfaceObservation, + ArtifactDocumentFormat.ObservationJson, + Kind: "determinism.json", + MediaType: "application/json", + Content: Encoding.UTF8.GetBytes(json), + View: "replay"); + } + private async Task PersistRubyPackagesAsync(ScanJobContext context, CancellationToken cancellationToken) { if (!context.Analysis.TryGet>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results)) diff --git a/src/Scanner/StellaOps.Scanner.Worker/Program.cs b/src/Scanner/StellaOps.Scanner.Worker/Program.cs index 3d1451295..480d5cf94 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Program.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Program.cs @@ -58,7 +58,8 @@ builder.Services.AddSingleton(new DeterminismContext( workerOptions.Determinism.FixedClock, workerOptions.Determinism.FixedInstantUtc, workerOptions.Determinism.RngSeed, - workerOptions.Determinism.FilterLogs)); + workerOptions.Determinism.FilterLogs, + workerOptions.Determinism.ConcurrencyLimit)); builder.Services.AddSingleton(_ => new DeterministicRandomProvider(workerOptions.Determinism.RngSeed)); builder.Services.AddScannerCache(builder.Configuration); builder.Services.AddSurfaceEnvironment(options => diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/Internal/NodePackageCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/Internal/NodePackageCollector.cs index ef3dd57bb..695f778e3 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/Internal/NodePackageCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/Internal/NodePackageCollector.cs @@ -645,7 +645,7 @@ internal static class NodePackageCollector packageSha256: packageSha256, isYarnPnp: yarnPnpPresent); - AttachEntrypoints(package, root, relativeDirectory); + AttachEntrypoints(context, package, root, relativeDirectory); return package; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs index ed0b6150a..35186fe3d 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationBuilder.cs @@ -4,15 +4,21 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations; internal static class RubyObservationBuilder { + private const string SchemaVersion = "stellaops.ruby.observation@1"; + public static RubyObservationDocument Build( IReadOnlyList packages, + RubyLockData lockData, RubyRuntimeGraph runtimeGraph, RubyCapabilities capabilities, + RubyBundlerConfig bundlerConfig, string? bundledWith) { ArgumentNullException.ThrowIfNull(packages); + ArgumentNullException.ThrowIfNull(lockData); ArgumentNullException.ThrowIfNull(runtimeGraph); ArgumentNullException.ThrowIfNull(capabilities); + ArgumentNullException.ThrowIfNull(bundlerConfig); var packageItems = packages .OrderBy(static package => package.Name, StringComparer.OrdinalIgnoreCase) @@ -20,6 +26,9 @@ internal static class RubyObservationBuilder .Select(CreatePackage) .ToImmutableArray(); + var entrypoints = BuildEntrypoints(runtimeGraph, packages); + var dependencyItems = BuildDependencyEdges(lockData); + var runtimeItems = packages .Select(package => CreateRuntimeEdge(package, runtimeGraph)) .Where(static edge => edge is not null) @@ -27,6 +36,8 @@ internal static class RubyObservationBuilder .OrderBy(static edge => edge.Package, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); + var environment = BuildEnvironment(lockData, bundlerConfig, capabilities, bundledWith); + var capabilitySummary = new RubyObservationCapabilitySummary( capabilities.UsesExec, capabilities.UsesNetwork, @@ -39,7 +50,134 @@ internal static class RubyObservationBuilder ? null : bundledWith.Trim(); - return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary, normalizedBundler); + return new RubyObservationDocument( + SchemaVersion, + packageItems, + entrypoints, + dependencyItems, + runtimeItems, + environment, + capabilitySummary, + normalizedBundler); + } + + private static ImmutableArray BuildEntrypoints( + RubyRuntimeGraph runtimeGraph, + IReadOnlyList packages) + { + var entrypoints = new List(); + var packageNames = packages.Select(static p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var entryFile in runtimeGraph.GetEntrypointFiles()) + { + var type = InferEntrypointType(entryFile); + var requiredGems = runtimeGraph.GetRequiredGems(entryFile) + .Where(gem => packageNames.Contains(gem)) + .OrderBy(static gem => gem, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + entrypoints.Add(new RubyObservationEntrypoint(entryFile, type, requiredGems)); + } + + return entrypoints + .OrderBy(static e => e.Path, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + } + + private static string InferEntrypointType(string path) + { + var fileName = Path.GetFileName(path); + + if (fileName.Equals("config.ru", StringComparison.OrdinalIgnoreCase)) + { + return "rack"; + } + + if (fileName.Equals("Rakefile", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".rake", StringComparison.OrdinalIgnoreCase)) + { + return "rake"; + } + + if (path.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || + path.Contains("\\bin\\", StringComparison.OrdinalIgnoreCase)) + { + return "executable"; + } + + if (fileName.Equals("Gemfile", StringComparison.OrdinalIgnoreCase)) + { + return "gemfile"; + } + + return "script"; + } + + private static RubyObservationEnvironment BuildEnvironment( + RubyLockData lockData, + RubyBundlerConfig bundlerConfig, + RubyCapabilities capabilities, + string? bundledWith) + { + var bundlePaths = bundlerConfig.BundlePaths + .OrderBy(static p => p, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + var gemfiles = bundlerConfig.Gemfiles + .Select(static p => p.Replace('\\', '/')) + .OrderBy(static p => p, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + var lockFiles = lockData.Entries + .Select(static e => e.LockFileRelativePath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static p => p, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + var frameworks = DetectFrameworks(capabilities) + .OrderBy(static f => f, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + + return new RubyObservationEnvironment( + RubyVersion: null, + BundlerVersion: string.IsNullOrWhiteSpace(bundledWith) ? null : bundledWith.Trim(), + bundlePaths, + gemfiles, + lockFiles, + frameworks); + } + + private static IEnumerable DetectFrameworks(RubyCapabilities capabilities) + { + if (capabilities.HasJobSchedulers) + { + foreach (var scheduler in capabilities.JobSchedulers) + { + yield return scheduler; + } + } + } + + private static ImmutableArray BuildDependencyEdges(RubyLockData lockData) + { + var edges = new List(); + + foreach (var entry in lockData.Entries) + { + var fromPackage = $"pkg:gem/{entry.Name}@{entry.Version}"; + foreach (var dep in entry.Dependencies) + { + edges.Add(new RubyObservationDependencyEdge( + fromPackage, + dep.DependencyName, + dep.VersionConstraint)); + } + } + + return edges + .OrderBy(static edge => edge.FromPackage, StringComparer.OrdinalIgnoreCase) + .ThenBy(static edge => edge.ToPackage, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); } private static RubyObservationPackage CreatePackage(RubyPackage package) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs index f0e975e77..4fac42c1a 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationDocument.cs @@ -2,9 +2,17 @@ using System.Collections.Immutable; namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations; +/// +/// AOC-compliant observation document for Ruby project analysis. +/// Contains components, entrypoints, dependency edges, and environment profiles. +/// internal sealed record RubyObservationDocument( + string Schema, ImmutableArray Packages, + ImmutableArray Entrypoints, + ImmutableArray DependencyEdges, ImmutableArray RuntimeEdges, + RubyObservationEnvironment Environment, RubyObservationCapabilitySummary Capabilities, string? BundledWith); @@ -18,6 +26,19 @@ internal sealed record RubyObservationPackage( string? Artifact, ImmutableArray Groups); +/// +/// Entrypoint detected in the Ruby project (Rakefile, bin scripts, config.ru, etc). +/// +internal sealed record RubyObservationEntrypoint( + string Path, + string Type, + ImmutableArray RequiredGems); + +internal sealed record RubyObservationDependencyEdge( + string FromPackage, + string ToPackage, + string? VersionConstraint); + internal sealed record RubyObservationRuntimeEdge( string Package, bool UsedByEntrypoint, @@ -25,6 +46,17 @@ internal sealed record RubyObservationRuntimeEdge( ImmutableArray Entrypoints, ImmutableArray Reasons); +/// +/// Environment profile with Ruby version, Bundler settings, and paths. +/// +internal sealed record RubyObservationEnvironment( + string? RubyVersion, + string? BundlerVersion, + ImmutableArray BundlePaths, + ImmutableArray Gemfiles, + ImmutableArray LockFiles, + ImmutableArray Frameworks); + internal sealed record RubyObservationCapabilitySummary( bool UsesExec, bool UsesNetwork, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs index f52d7c6e5..dab75691d 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Observations/RubyObservationSerializer.cs @@ -17,8 +17,12 @@ internal static class RubyObservationSerializer { writer.WriteStartObject(); + writer.WriteString("$schema", document.Schema); WritePackages(writer, document.Packages); + WriteEntrypoints(writer, document.Entrypoints); + WriteDependencyEdges(writer, document.DependencyEdges); WriteRuntimeEdges(writer, document.RuntimeEdges); + WriteEnvironment(writer, document.Environment); WriteCapabilities(writer, document.Capabilities); WriteBundledWith(writer, document.BundledWith); @@ -72,6 +76,46 @@ internal static class RubyObservationSerializer writer.WriteEndArray(); } + private static void WriteEntrypoints(Utf8JsonWriter writer, ImmutableArray entrypoints) + { + writer.WritePropertyName("entrypoints"); + writer.WriteStartArray(); + foreach (var entrypoint in entrypoints) + { + writer.WriteStartObject(); + writer.WriteString("path", entrypoint.Path); + writer.WriteString("type", entrypoint.Type); + if (entrypoint.RequiredGems.Length > 0) + { + WriteStringArray(writer, "requiredGems", entrypoint.RequiredGems); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + private static void WriteDependencyEdges(Utf8JsonWriter writer, ImmutableArray dependencyEdges) + { + writer.WritePropertyName("dependencyEdges"); + writer.WriteStartArray(); + foreach (var edge in dependencyEdges) + { + writer.WriteStartObject(); + writer.WriteString("from", edge.FromPackage); + writer.WriteString("to", edge.ToPackage); + if (!string.IsNullOrWhiteSpace(edge.VersionConstraint)) + { + writer.WriteString("constraint", edge.VersionConstraint); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + private static void WriteRuntimeEdges(Utf8JsonWriter writer, ImmutableArray runtimeEdges) { writer.WritePropertyName("runtimeEdges"); @@ -90,6 +134,44 @@ internal static class RubyObservationSerializer writer.WriteEndArray(); } + private static void WriteEnvironment(Utf8JsonWriter writer, RubyObservationEnvironment environment) + { + writer.WritePropertyName("environment"); + writer.WriteStartObject(); + + if (!string.IsNullOrWhiteSpace(environment.RubyVersion)) + { + writer.WriteString("rubyVersion", environment.RubyVersion); + } + + if (!string.IsNullOrWhiteSpace(environment.BundlerVersion)) + { + writer.WriteString("bundlerVersion", environment.BundlerVersion); + } + + if (environment.BundlePaths.Length > 0) + { + WriteStringArray(writer, "bundlePaths", environment.BundlePaths); + } + + if (environment.Gemfiles.Length > 0) + { + WriteStringArray(writer, "gemfiles", environment.Gemfiles); + } + + if (environment.LockFiles.Length > 0) + { + WriteStringArray(writer, "lockfiles", environment.LockFiles); + } + + if (environment.Frameworks.Length > 0) + { + WriteStringArray(writer, "frameworks", environment.Frameworks); + } + + writer.WriteEndObject(); + } + private static void WriteCapabilities(Utf8JsonWriter writer, RubyObservationCapabilitySummary summary) { writer.WritePropertyName("capabilities"); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockCollector.cs index 5b08edfa9..7dcb4b184 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockCollector.cs @@ -21,6 +21,8 @@ internal static class RubyLockCollector "coverage" }; + private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" }; + private const int MaxDiscoveryDepth = 3; private static readonly IReadOnlyCollection DefaultGroups = new[] { "default" }; @@ -61,6 +63,7 @@ internal static class RubyLockCollector spec.Source, spec.Platform, groups, + spec.Dependencies, relativeLockPath)); } } @@ -186,6 +189,20 @@ internal static class RubyLockCollector TryAdd(candidate); } + // Also discover lock files in container layers + foreach (var layerRoot in EnumerateLayerRoots(rootPath)) + { + foreach (var name in LockFileNames) + { + TryAdd(Path.Combine(layerRoot, name)); + } + + foreach (var candidate in EnumerateLockFiles(layerRoot)) + { + TryAdd(candidate); + } + } + return discovered .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -294,4 +311,53 @@ internal static class RubyLockCollector Path.GetFullPath(manifestDirectory), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } + + /// + /// Enumerates OCI container layer roots for Ruby project discovery. + /// Looks for layers/, .layers/, layer/ directories containing layer subdirectories. + /// + private static IEnumerable EnumerateLayerRoots(string workspaceRoot) + { + foreach (var candidate in LayerRootCandidates) + { + var root = Path.Combine(workspaceRoot, candidate); + if (!Directory.Exists(root)) + { + continue; + } + + IEnumerable? directories = null; + try + { + directories = Directory.EnumerateDirectories(root); + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + + if (directories is null) + { + continue; + } + + foreach (var layerDirectory in directories) + { + // Check for fs/ subdirectory (extracted layer filesystem) + var fsDirectory = Path.Combine(layerDirectory, "fs"); + if (Directory.Exists(fsDirectory)) + { + yield return fsDirectory; + } + else + { + yield return layerDirectory; + } + } + } + } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockEntry.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockEntry.cs index 0f6dec963..aad855c0d 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockEntry.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockEntry.cs @@ -6,4 +6,5 @@ internal sealed record RubyLockEntry( string Source, string? Platform, IReadOnlyCollection Groups, + IReadOnlyList Dependencies, string LockFileRelativePath); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs index 241e24253..6b2a6685c 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyLockParser.cs @@ -15,6 +15,7 @@ internal static class RubyLockParser } private static readonly Regex SpecLineRegex = new(@"^\s{4}(?[^\s]+)\s\((?[^)]+)\)", RegexOptions.Compiled); + private static readonly Regex DependencyLineRegex = new(@"^\s{6}(?[^\s]+)(?:\s\((?[^)]+)\))?", RegexOptions.Compiled); public static RubyLockParserResult Parse(string contents) { @@ -23,13 +24,14 @@ internal static class RubyLockParser return new RubyLockParserResult(Array.Empty(), string.Empty); } - var entries = new List(); + var specBuilders = new List(); var section = RubyLockSection.None; var bundledWith = string.Empty; var inSpecs = false; string? currentRemote = null; string? currentRevision = null; string? currentPath = null; + SpecBuilder? currentSpec = null; using var reader = new StringReader(contents); string? line; @@ -47,6 +49,7 @@ internal static class RubyLockParser currentRemote = null; currentRevision = null; currentPath = null; + currentSpec = null; if (section == RubyLockSection.Gem) { @@ -76,13 +79,15 @@ internal static class RubyLockParser ref currentRemote, ref currentRevision, ref currentPath, - entries); + ref currentSpec, + specBuilders); break; default: break; } } + var entries = specBuilders.Select(static builder => builder.Build()).ToArray(); return new RubyLockParserResult(entries, bundledWith); } @@ -93,7 +98,8 @@ internal static class RubyLockParser ref string? currentRemote, ref string? currentRevision, ref string? currentPath, - List entries) + ref SpecBuilder? currentSpec, + List specBuilders) { if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase)) { @@ -130,15 +136,33 @@ internal static class RubyLockParser return; } - var match = SpecLineRegex.Match(line); - if (!match.Success) + // Check for nested dependency line (6 spaces indent) + if (line.Length > 6 && line.StartsWith(" ") && !char.IsWhiteSpace(line[6])) { + if (currentSpec is not null) + { + var depMatch = DependencyLineRegex.Match(line); + if (depMatch.Success) + { + var depName = depMatch.Groups["name"].Value.Trim(); + var constraint = depMatch.Groups["constraint"].Success + ? depMatch.Groups["constraint"].Value.Trim() + : null; + + if (!string.IsNullOrEmpty(depName)) + { + currentSpec.Dependencies.Add(new RubyDependencyEdge(depName, constraint)); + } + } + } + return; } - if (line.Length > 4 && char.IsWhiteSpace(line[4])) + // Top-level spec line (4 spaces indent) + var match = SpecLineRegex.Match(line); + if (!match.Success) { - // Nested dependency entry under a spec. return; } @@ -151,7 +175,30 @@ internal static class RubyLockParser var (version, platform) = ParseVersion(match.Groups["version"].Value); var source = ResolveSource(section, currentRemote, currentRevision, currentPath); - entries.Add(new RubyLockParserEntry(name, version, source, platform)); + currentSpec = new SpecBuilder(name, version, source, platform); + specBuilders.Add(currentSpec); + } + + private sealed class SpecBuilder + { + public SpecBuilder(string name, string version, string source, string? platform) + { + Name = name; + Version = version; + Source = source; + Platform = platform; + } + + public string Name { get; } + public string Version { get; } + public string Source { get; } + public string? Platform { get; } + public List Dependencies { get; } = new(); + + public RubyLockParserEntry Build() + { + return new RubyLockParserEntry(Name, Version, Source, Platform, Dependencies.ToArray()); + } } private static RubyLockSection ParseSection(string value) @@ -213,6 +260,15 @@ internal static class RubyLockParser } } -internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform); +internal sealed record RubyLockParserEntry( + string Name, + string Version, + string Source, + string? Platform, + IReadOnlyList Dependencies); -internal sealed record RubyLockParserResult(IReadOnlyList Entries, string BundledWith); +internal sealed record RubyDependencyEdge(string DependencyName, string? VersionConstraint); + +internal sealed record RubyLockParserResult( + IReadOnlyList Entries, + string BundledWith); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyRuntimeGraphBuilder.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyRuntimeGraphBuilder.cs index 0c9513334..c9d1c09f2 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyRuntimeGraphBuilder.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyRuntimeGraphBuilder.cs @@ -374,6 +374,38 @@ internal sealed class RubyRuntimeGraph return false; } + /// + /// Gets all entrypoint files across all gem usages. + /// + public IEnumerable GetEntrypointFiles() + { + return _usages.Values + .Where(static usage => usage.HasEntrypoints) + .SelectMany(static usage => usage.Entrypoints) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the gems required by a specific file. + /// + public IEnumerable GetRequiredGems(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + yield break; + } + + var normalizedPath = filePath.Replace('\\', '/'); + + foreach (var (gemName, usage) in _usages) + { + if (usage.ReferencingFiles.Any(f => f.Equals(normalizedPath, StringComparison.OrdinalIgnoreCase))) + { + yield return gemName; + } + } + } + private static IEnumerable EnumerateCandidateKeys(string name) { if (string.IsNullOrWhiteSpace(name)) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs index 93c9e01aa..f26b1d6ea 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/RubyVendorArtifactCollector.cs @@ -8,6 +8,8 @@ internal static class RubyVendorArtifactCollector Path.Combine(".bundle", "cache") }; + private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" }; + private static readonly string[] DirectoryBlockList = { ".git", @@ -65,6 +67,14 @@ internal static class RubyVendorArtifactCollector TryAdd(Path.Combine(bundlePath, "cache")); } + // Also check container layers for vendor directories and gems + foreach (var layerRoot in EnumerateLayerRoots(context.RootPath)) + { + TryAdd(Path.Combine(layerRoot, "vendor", "cache")); + TryAdd(Path.Combine(layerRoot, "vendor", "bundle")); + TryAdd(Path.Combine(layerRoot, ".bundle", "cache")); + } + var artifacts = new List(); foreach (var root in roots.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)) { @@ -261,6 +271,55 @@ internal static class RubyVendorArtifactCollector return path + Path.DirectorySeparatorChar; } + + /// + /// Enumerates OCI container layer roots for Ruby vendor artifact discovery. + /// Looks for layers/, .layers/, layer/ directories containing layer subdirectories. + /// + private static IEnumerable EnumerateLayerRoots(string workspaceRoot) + { + foreach (var candidate in LayerRootCandidates) + { + var root = Path.Combine(workspaceRoot, candidate); + if (!Directory.Exists(root)) + { + continue; + } + + IEnumerable? directories = null; + try + { + directories = Directory.EnumerateDirectories(root); + } + catch (IOException) + { + continue; + } + catch (UnauthorizedAccessException) + { + continue; + } + + if (directories is null) + { + continue; + } + + foreach (var layerDirectory in directories) + { + // Check for fs/ subdirectory (extracted layer filesystem) + var fsDirectory = Path.Combine(layerDirectory, "fs"); + if (Directory.Exists(fsDirectory)) + { + yield return fsDirectory; + } + else + { + yield return layerDirectory; + } + } + } + } } internal sealed record RubyVendorArtifact( diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeShim.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeShim.cs new file mode 100644 index 000000000..a03b14d86 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeShim.cs @@ -0,0 +1,307 @@ +using System.Text; + +namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime; + +/// +/// Provides the Ruby runtime shim that captures runtime events via TracePoint into NDJSON. +/// This shim is written to disk alongside the analyzer to be invoked by the worker/CLI. +/// +internal static class RubyRuntimeShim +{ + private const string ShimFileName = "trace-shim.rb"; + + public static string FileName => ShimFileName; + + public static async Task WriteAsync(string directory, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + Directory.CreateDirectory(directory); + + var path = Path.Combine(directory, ShimFileName); + await File.WriteAllTextAsync(path, ShimSource, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + return path; + } + + // NOTE: This shim is intentionally self-contained, offline, and deterministic. + // Uses Ruby's TracePoint API for runtime introspection with append-only evidence collection. + private const string ShimSource = """ +# frozen_string_literal: true + +# Ruby runtime trace shim (offline, deterministic) +# Captures require, load, and method call events via TracePoint. +# Emits NDJSON to ruby-runtime.ndjson for evidence collection. + +require 'json' +require 'digest/sha2' +require 'time' + +module StellaTracer + EVENTS = [] + MUTEX = Mutex.new + CWD = Dir.pwd.tr('\\', '/') + ENTRYPOINT_ENV = 'STELLA_RUBY_ENTRYPOINT' + OUTPUT_FILE = 'ruby-runtime.ndjson' + + # Patterns for redacting sensitive data + REDACT_PATTERNS = [ + /password/i, + /secret/i, + /api[_-]?key/i, + /auth[_-]?token/i, + /bearer/i, + /credential/i, + /private[_-]?key/i + ].freeze + + # Gems known to have security-relevant capabilities + CAPABILITY_GEMS = { + exec: %w[open3 open4 shellwords pty childprocess posix-spawn].freeze, + net: %w[net/http net/https net/ftp socket httparty faraday rest-client typhoeus patron curb excon httpclient].freeze, + serialize: %w[yaml json marshal oj msgpack ox multi_json yajl].freeze, + scheduler: %w[rufus-scheduler clockwork sidekiq resque delayed_job good_job que karafka sucker_punch shoryuken].freeze, + ffi: %w[ffi fiddle].freeze + }.freeze + + class << self + def now_iso + Time.now.utc.iso8601(3) + end + + def sha256_hex(value) + Digest::SHA256.hexdigest(value.to_s) + end + + def relative_path(path) + candidate = path.to_s.tr('\\', '/') + return candidate if candidate.empty? + + # Strip file:// prefix if present + candidate = candidate.sub(%r{^file://}, '') + + # Make absolute if relative + unless candidate.start_with?('/') || candidate.match?(/^[A-Za-z]:/) + candidate = File.join(CWD, candidate) + end + + # Make relative to CWD + if candidate.start_with?(CWD) + offset = CWD.end_with?('/') ? CWD.length : CWD.length + 1 + candidate = candidate[offset..] + end + + candidate&.sub(%r{^\./}, '')&.sub(%r{^/+}, '') || '.' + end + + def normalize_feature(path) + rel = relative_path(path) + { + normalized: rel, + path_sha256: sha256_hex(rel) + } + end + + def redact_value(value) + str = value.to_s + REDACT_PATTERNS.any? { |pat| str.match?(pat) } ? '[REDACTED]' : str + end + + def detect_capability(feature_name) + CAPABILITY_GEMS.each do |cap, gems| + return cap if gems.any? { |g| feature_name.include?(g) } + end + nil + end + + def add_event(evt) + MUTEX.synchronize { EVENTS << evt } + end + + def record_require(feature, path, success) + normalized = normalize_feature(path || feature) + capability = detect_capability(feature) + + event = { + type: 'ruby.require', + ts: now_iso, + feature: feature, + module: normalized, + success: success + } + event[:capability] = capability if capability + add_event(event) + end + + def record_load(path, wrap) + normalized = normalize_feature(path) + add_event({ + type: 'ruby.load', + ts: now_iso, + module: normalized, + wrap: wrap + }) + end + + def record_method_call(klass, method_id, location) + return if location.nil? + + path = relative_path(location.path) + add_event({ + type: 'ruby.method.call', + ts: now_iso, + class: redact_value(klass.to_s), + method: method_id.to_s, + location: { + path: path, + line: location.lineno, + path_sha256: sha256_hex(path) + } + }) + end + + def record_error(message, location = nil) + event = { + type: 'ruby.runtime.error', + ts: now_iso, + message: redact_value(message) + } + + if location + event[:location] = { + path: relative_path(location), + path_sha256: sha256_hex(relative_path(location)) + } + end + + add_event(event) + end + + def flush + MUTEX.synchronize do + sorted = EVENTS.sort_by { |e| [e[:ts].to_s, e[:type].to_s] } + File.open(OUTPUT_FILE, 'w') do |f| + sorted.each { |e| f.puts(JSON.generate(e)) } + end + end + rescue => e + warn "stella-tracer: failed to write trace: #{e.message}" + end + + def enabled_capabilities + caps = Set.new + $LOADED_FEATURES.each do |feature| + cap = detect_capability(feature) + caps << cap if cap + end + caps.to_a.sort + end + end +end + +# Track loaded features at startup +$stella_initial_features = $LOADED_FEATURES.dup + +# Hook require +module Kernel + alias_method :stella_original_require, :require + alias_method :stella_original_require_relative, :require_relative + alias_method :stella_original_load, :load + + def require(feature) + success = false + result = stella_original_require(feature) + success = result + result + rescue LoadError => e + StellaTracer.record_error("LoadError: #{e.message}", feature) + raise + ensure + path = $LOADED_FEATURES.find { |f| f.include?(feature.to_s.gsub(/\.rb$/, '')) } + StellaTracer.record_require(feature.to_s, path, success) + end + + def require_relative(feature) + # Resolve the path relative to the caller + caller_path = caller_locations(1, 1)&.first&.path || __FILE__ + dir = File.dirname(caller_path) + absolute = File.expand_path(feature, dir) + require(absolute) + end + + def load(path, wrap = false) + result = stella_original_load(path, wrap) + StellaTracer.record_load(path.to_s, wrap) + result + rescue => e + StellaTracer.record_error("LoadError: #{e.message}", path) + raise + end +end + +# TracePoint for method calls (optional, configurable) +$stella_method_trace = nil + +def stella_enable_method_trace(filter_classes: nil) + $stella_method_trace = TracePoint.new(:call) do |tp| + next if tp.path&.start_with?(' e + StellaTracer.record_error("#{e.class}: #{e.message}", entrypoint) + raise +end +"""; +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceReader.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceReader.cs new file mode 100644 index 000000000..0bb936200 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceReader.cs @@ -0,0 +1,268 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime; + +/// +/// Reads and parses Ruby runtime trace NDJSON output. +/// +internal static class RubyRuntimeTraceReader +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + /// + /// Reads runtime trace events from an NDJSON file. + /// + public static async Task ReadAsync(string path, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (!File.Exists(path)) + { + return RubyRuntimeTrace.Empty; + } + + var events = new List(); + var requires = new List(); + var loads = new List(); + var methodCalls = new List(); + var errors = new List(); + string? rubyVersion = null; + string? rubyPlatform = null; + string[]? finalCapabilities = null; + int? loadedFeaturesCount = null; + + await foreach (var line in File.ReadLinesAsync(path, cancellationToken).ConfigureAwait(false)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if (!root.TryGetProperty("type", out var typeProp)) + { + continue; + } + + var type = typeProp.GetString(); + var timestamp = root.TryGetProperty("ts", out var tsProp) ? tsProp.GetString() : null; + + switch (type) + { + case "ruby.runtime.start": + rubyVersion = root.TryGetProperty("ruby_version", out var vProp) ? vProp.GetString() : null; + rubyPlatform = root.TryGetProperty("ruby_platform", out var pProp) ? pProp.GetString() : null; + break; + + case "ruby.runtime.end": + loadedFeaturesCount = root.TryGetProperty("loaded_features_count", out var fcProp) + ? fcProp.GetInt32() + : null; + if (root.TryGetProperty("capabilities", out var capsProp) && capsProp.ValueKind == JsonValueKind.Array) + { + finalCapabilities = capsProp.EnumerateArray() + .Select(e => e.GetString()) + .Where(s => s is not null) + .Cast() + .ToArray(); + } + break; + + case "ruby.require": + var reqFeature = root.TryGetProperty("feature", out var fProp) ? fProp.GetString() : null; + var reqSuccess = root.TryGetProperty("success", out var sProp) && sProp.GetBoolean(); + var reqCapability = root.TryGetProperty("capability", out var cProp) ? cProp.GetString() : null; + var reqModule = ParseModuleRef(root); + + if (reqFeature is not null) + { + requires.Add(new RubyRequireEvent( + timestamp, + reqFeature, + reqModule?.Normalized, + reqModule?.PathSha256, + reqSuccess, + reqCapability)); + } + break; + + case "ruby.load": + var loadModule = ParseModuleRef(root); + var wrap = root.TryGetProperty("wrap", out var wProp) && wProp.GetBoolean(); + + if (loadModule is not null) + { + loads.Add(new RubyLoadEvent( + timestamp, + loadModule.Normalized, + loadModule.PathSha256, + wrap)); + } + break; + + case "ruby.method.call": + var className = root.TryGetProperty("class", out var clsProp) ? clsProp.GetString() : null; + var methodName = root.TryGetProperty("method", out var mtdProp) ? mtdProp.GetString() : null; + var location = ParseLocation(root); + + if (className is not null && methodName is not null) + { + methodCalls.Add(new RubyMethodCallEvent( + timestamp, + className, + methodName, + location?.Path, + location?.Line)); + } + break; + + case "ruby.runtime.error": + var errorMsg = root.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null; + var errorLocation = root.TryGetProperty("location", out var locProp) ? ParseLocationDirect(locProp) : null; + + if (errorMsg is not null) + { + errors.Add(new RubyRuntimeErrorEvent(timestamp, errorMsg, errorLocation?.Path)); + } + break; + } + + events.Add(new RubyRuntimeEvent(type ?? "unknown", timestamp)); + } + catch (JsonException) + { + // Skip malformed lines + } + } + + return new RubyRuntimeTrace( + events.ToArray(), + requires.ToArray(), + loads.ToArray(), + methodCalls.ToArray(), + errors.ToArray(), + rubyVersion, + rubyPlatform, + finalCapabilities ?? [], + loadedFeaturesCount); + } + + private static ModuleRef? ParseModuleRef(JsonElement root) + { + if (!root.TryGetProperty("module", out var moduleProp) || moduleProp.ValueKind != JsonValueKind.Object) + { + return null; + } + + var normalized = moduleProp.TryGetProperty("normalized", out var nProp) ? nProp.GetString() : null; + var sha256 = moduleProp.TryGetProperty("path_sha256", out var sProp) ? sProp.GetString() : null; + + return normalized is not null ? new ModuleRef(normalized, sha256) : null; + } + + private static LocationRef? ParseLocation(JsonElement root) + { + if (!root.TryGetProperty("location", out var locProp) || locProp.ValueKind != JsonValueKind.Object) + { + return null; + } + + return ParseLocationDirect(locProp); + } + + private static LocationRef? ParseLocationDirect(JsonElement locProp) + { + if (locProp.ValueKind != JsonValueKind.Object) + { + return null; + } + + var path = locProp.TryGetProperty("path", out var pProp) ? pProp.GetString() : null; + var line = locProp.TryGetProperty("line", out var lProp) ? lProp.GetInt32() : (int?)null; + + return path is not null ? new LocationRef(path, line) : null; + } + + private sealed record ModuleRef(string Normalized, string? PathSha256); + private sealed record LocationRef(string Path, int? Line); +} + +/// +/// Represents a complete Ruby runtime trace. +/// +internal sealed record RubyRuntimeTrace( + RubyRuntimeEvent[] Events, + RubyRequireEvent[] Requires, + RubyLoadEvent[] Loads, + RubyMethodCallEvent[] MethodCalls, + RubyRuntimeErrorEvent[] Errors, + string? RubyVersion, + string? RubyPlatform, + string[] Capabilities, + int? LoadedFeaturesCount) +{ + public static RubyRuntimeTrace Empty { get; } = new( + [], + [], + [], + [], + [], + null, + null, + [], + null); + + public bool IsEmpty => Events.Length == 0; +} + +/// +/// Base runtime event with type and timestamp. +/// +internal sealed record RubyRuntimeEvent(string Type, string? Timestamp); + +/// +/// A require event capturing a gem/file being loaded. +/// +internal sealed record RubyRequireEvent( + string? Timestamp, + string Feature, + string? NormalizedPath, + string? PathSha256, + bool Success, + string? Capability); + +/// +/// A load event for explicit file loads. +/// +internal sealed record RubyLoadEvent( + string? Timestamp, + string NormalizedPath, + string? PathSha256, + bool Wrap); + +/// +/// A method call event from TracePoint. +/// +internal sealed record RubyMethodCallEvent( + string? Timestamp, + string ClassName, + string MethodName, + string? Path, + int? Line); + +/// +/// A runtime error event. +/// +internal sealed record RubyRuntimeErrorEvent( + string? Timestamp, + string Message, + string? Path); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceRunner.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceRunner.cs new file mode 100644 index 000000000..49dd62b48 --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/Internal/Runtime/RubyRuntimeTraceRunner.cs @@ -0,0 +1,164 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using StellaOps.Scanner.Analyzers.Lang; + +namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime; + +/// +/// Optional harness that executes the emitted Ruby runtime shim when an entrypoint is provided via environment variable. +/// This keeps runtime capture opt-in and offline-friendly. +/// +internal static class RubyRuntimeTraceRunner +{ + private const string EntrypointEnvVar = "STELLA_RUBY_ENTRYPOINT"; + private const string BinaryEnvVar = "STELLA_RUBY_BINARY"; + private const string TraceClassesEnvVar = "STELLA_RUBY_TRACE_CLASSES"; + private const string RuntimeFileName = "ruby-runtime.ndjson"; + private const int DefaultTimeoutMs = 60_000; // 1 minute default timeout + + public static async Task TryExecuteAsync( + LanguageAnalyzerContext context, + ILogger? logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + var entrypoint = Environment.GetEnvironmentVariable(EntrypointEnvVar); + if (string.IsNullOrWhiteSpace(entrypoint)) + { + logger?.LogDebug("Ruby runtime trace skipped: {EnvVar} not set", EntrypointEnvVar); + return false; + } + + var entrypointPath = Path.GetFullPath(Path.Combine(context.RootPath, entrypoint)); + if (!File.Exists(entrypointPath)) + { + logger?.LogWarning("Ruby runtime trace skipped: entrypoint '{Entrypoint}' missing", entrypointPath); + return false; + } + + var shimPath = Path.Combine(context.RootPath, RubyRuntimeShim.FileName); + if (!File.Exists(shimPath)) + { + await RubyRuntimeShim.WriteAsync(context.RootPath, cancellationToken).ConfigureAwait(false); + } + + var binary = Environment.GetEnvironmentVariable(BinaryEnvVar); + if (string.IsNullOrWhiteSpace(binary)) + { + binary = "ruby"; + } + + var startInfo = new ProcessStartInfo + { + FileName = binary, + WorkingDirectory = context.RootPath, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + + // Ruby arguments for sandboxed execution + // -W0: Suppress warnings + // -T: Taint mode (restrict dangerous operations) - optional, may not be available in all Ruby versions + startInfo.ArgumentList.Add("-W0"); + startInfo.ArgumentList.Add(shimPath); + + // Pass through the entrypoint + startInfo.Environment[EntrypointEnvVar] = entrypointPath; + + // Pass through trace classes filter if set + var traceClasses = Environment.GetEnvironmentVariable(TraceClassesEnvVar); + if (!string.IsNullOrWhiteSpace(traceClasses)) + { + startInfo.Environment[TraceClassesEnvVar] = traceClasses; + } + + // Sandbox guidance: Set restrictive environment variables + startInfo.Environment["BUNDLE_DISABLE_EXEC_LOAD"] = "1"; + startInfo.Environment["BUNDLE_FROZEN"] = "1"; + + try + { + using var process = Process.Start(startInfo); + if (process is null) + { + logger?.LogWarning("Ruby runtime trace skipped: failed to start 'ruby' process"); + return false; + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(DefaultTimeoutMs); + + try + { + await process.WaitForExitAsync(cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timeout - kill the process + logger?.LogWarning("Ruby runtime trace timed out after {Timeout}ms", DefaultTimeoutMs); + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Best effort + } + return false; + } + + if (process.ExitCode != 0) + { + var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); + logger?.LogWarning( + "Ruby runtime trace failed with exit code {ExitCode}. stderr: {Error}", + process.ExitCode, + Truncate(stderr)); + // Still check for output file - partial traces may be useful + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Ruby runtime trace skipped: {Message}", ex.Message); + return false; + } + + var runtimePath = Path.Combine(context.RootPath, RuntimeFileName); + if (!File.Exists(runtimePath)) + { + logger?.LogWarning( + "Ruby runtime trace finished but did not emit {RuntimeFile}", + RuntimeFileName); + return false; + } + + logger?.LogDebug("Ruby runtime trace completed: {RuntimeFile}", runtimePath); + return true; + } + + /// + /// Gets the path to the expected runtime trace output file. + /// + public static string GetOutputPath(string rootPath) => Path.Combine(rootPath, RuntimeFileName); + + /// + /// Checks if a runtime trace output exists for the given root path. + /// + public static bool OutputExists(string rootPath) => File.Exists(GetOutputPath(rootPath)); + + private static string Truncate(string? value, int maxLength = 400) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value.Length <= maxLength ? value : value[..maxLength]; + } +} diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs index fb86fdd9b..3bc7a65ac 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/RubyLanguageAnalyzer.cs @@ -30,6 +30,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer var capabilities = await RubyCapabilityDetector.DetectAsync(context, cancellationToken).ConfigureAwait(false); var runtimeGraph = await RubyRuntimeGraphBuilder.BuildAsync(context, packages, cancellationToken).ConfigureAwait(false); + var bundlerConfig = RubyBundlerConfig.Load(context.RootPath); foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal)) { @@ -50,7 +51,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer if (packages.Count > 0) { - EmitObservation(context, writer, packages, runtimeGraph, capabilities, lockData.BundledWith); + EmitObservation(context, writer, packages, lockData, runtimeGraph, capabilities, bundlerConfig, lockData.BundledWith); } } @@ -86,23 +87,28 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer LanguageAnalyzerContext context, LanguageComponentWriter writer, IReadOnlyList packages, + RubyLockData lockData, RubyRuntimeGraph runtimeGraph, RubyCapabilities capabilities, + RubyBundlerConfig bundlerConfig, string? bundledWith) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(packages); + ArgumentNullException.ThrowIfNull(lockData); ArgumentNullException.ThrowIfNull(runtimeGraph); ArgumentNullException.ThrowIfNull(capabilities); + ArgumentNullException.ThrowIfNull(bundlerConfig); - var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities, bundledWith); + var observationDocument = RubyObservationBuilder.Build(packages, lockData, runtimeGraph, capabilities, bundlerConfig, bundledWith); var observationJson = RubyObservationSerializer.Serialize(observationDocument); var observationHash = RubyObservationSerializer.ComputeSha256(observationJson); var observationBytes = Encoding.UTF8.GetBytes(observationJson); var observationMetadata = BuildObservationMetadata( packages.Count, + observationDocument.DependencyEdges.Length, observationDocument.RuntimeEdges.Length, observationDocument.Capabilities, observationDocument.BundledWith); @@ -132,11 +138,13 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer private static IEnumerable> BuildObservationMetadata( int packageCount, + int dependencyEdgeCount, int runtimeEdgeCount, RubyObservationCapabilitySummary capabilities, string? bundledWith) { yield return new KeyValuePair("ruby.observation.packages", packageCount.ToString(CultureInfo.InvariantCulture)); + yield return new KeyValuePair("ruby.observation.dependency_edges", dependencyEdgeCount.ToString(CultureInfo.InvariantCulture)); yield return new KeyValuePair("ruby.observation.runtime_edges", runtimeEdgeCount.ToString(CultureInfo.InvariantCulture)); yield return new KeyValuePair("ruby.observation.capability.exec", capabilities.UsesExec ? "true" : "false"); yield return new KeyValuePair("ruby.observation.capability.net", capabilities.UsesNetwork ? "true" : "false"); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md index 6543230e6..d024007b0 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/TASKS.md @@ -6,3 +6,8 @@ | `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector merged with vendor cache ingestion; workspace overrides, bundler groups, git/path fixture, and offline-kit mirror updated. | | `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. | | `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. | +| `SCANNER-ANALYZERS-RUBY-28-001` | DONE (2025-11-27) | Added OCI container layer support (layers/, .layers/, layer/) to RubyLockCollector and RubyVendorArtifactCollector for VFS/container workspace discovery. Existing implementation already covered Gemfile/lock, vendor/bundle, .gem archives, .bundle/config, Rack configs, and framework fingerprints. | +| `SCANNER-ANALYZERS-RUBY-28-002` | DONE (2025-11-27) | Enhanced RubyLockParser to capture gem dependency edges with version constraints from Gemfile.lock; added RubyDependencyEdge type; updated RubyLockEntry, RubyObservationDocument, observation builder and serializer to produce dependencyEdges with from/to/constraint fields. PURLs and resolver traces now included. | +| `SCANNER-ANALYZERS-RUBY-28-003` | DONE (2025-11-27) | AOC-compliant observations integration: added schema field, RubyObservationEntrypoint and RubyObservationEnvironment types; builder generates entrypoints (path/type/requiredGems) and environment profiles (bundlePaths/gemfiles/lockfiles/frameworks); RubyRuntimeGraph provides GetEntrypointFiles/GetRequiredGems; bundlerConfig wired through analyzer for complete observation coverage. | +| `SCANNER-ANALYZERS-RUBY-28-004` | DONE (2025-11-27) | Fixtures/benchmarks for Ruby analyzer: created cli-app fixture with Thor/TTY-Prompt CLI gems, updated expected.json golden files for simple-app and complex-app with dependency edges format, added CliWorkspaceProducesDeterministicOutputAsync test; all 4 determinism tests pass. | +| `SCANNER-ANALYZERS-RUBY-28-005` | DONE (2025-11-27) | Runtime capture (tracepoint) hooks: created Internal/Runtime/ with RubyRuntimeShim.cs (trace-shim.rb using TracePoint for require/load events, capability detection, sensitive data redaction), RubyRuntimeTraceRunner.cs (opt-in harness via STELLA_RUBY_ENTRYPOINT env var, sandbox guidance), and RubyRuntimeTraceReader.cs (NDJSON parser for trace events). | diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/manifest.json b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/manifest.json new file mode 100644 index 000000000..3575873fa --- /dev/null +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/manifest.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": "1.0", + "id": "stellaops.analyzer.lang.ruby", + "displayName": "StellaOps Ruby Analyzer", + "version": "0.1.0", + "requiresRestart": true, + "entryPoint": { + "type": "dotnet", + "assembly": "StellaOps.Scanner.Analyzers.Lang.Ruby.dll", + "typeName": "StellaOps.Scanner.Analyzers.Lang.Ruby.RubyAnalyzerPlugin" + }, + "capabilities": [ + "language-analyzer", + "ruby", + "rubygems", + "bundler" + ], + "metadata": { + "org.stellaops.analyzer.language": "ruby", + "org.stellaops.analyzer.kind": "language", + "org.stellaops.restart.required": "true", + "org.stellaops.analyzer.runtime-capture": "optional" + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile new file mode 100644 index 000000000..91ff863f6 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile @@ -0,0 +1,10 @@ +source "https://rubygems.org" + +ruby "3.2.0" + +gem "thor", "~> 1.3" +gem "tty-prompt", "~> 0.23" + +group :development do + gem "bundler", "~> 2.5" +end diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile.lock b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile.lock new file mode 100644 index 000000000..797e2d449 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/Gemfile.lock @@ -0,0 +1,29 @@ +GEM + remote: https://rubygems.org/ + specs: + bundler (2.5.3) + pastel (0.8.0) + tty-color (~> 0.5) + thor (1.3.0) + tty-color (0.6.0) + tty-cursor (0.7.1) + tty-prompt (0.23.1) + pastel (~> 0.8) + tty-reader (~> 0.8) + tty-reader (0.9.0) + tty-cursor (~> 0.7) + tty-screen (~> 0.8) + wisper (~> 2.0) + tty-screen (0.8.2) + wisper (2.0.1) + +PLATFORMS + ruby + +DEPENDENCIES + bundler (~> 2.5) + thor (~> 1.3) + tty-prompt (~> 0.23) + +BUNDLED WITH + 2.5.3 diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/expected.json b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/expected.json new file mode 100644 index 000000000..914549345 --- /dev/null +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/cli-app/expected.json @@ -0,0 +1,226 @@ +[ + { + "analyzerId": "ruby", + "componentKey": "observation::ruby", + "name": "Ruby Observation Summary", + "type": "ruby-observation", + "usedByEntrypoint": false, + "metadata": { + "ruby.observation.bundler_version": "2.5.3", + "ruby.observation.capability.exec": "false", + "ruby.observation.capability.net": "false", + "ruby.observation.capability.schedulers": "0", + "ruby.observation.capability.serialization": "false", + "ruby.observation.dependency_edges": "6", + "ruby.observation.packages": "9", + "ruby.observation.runtime_edges": "0" + }, + "evidence": [ + { + "kind": "derived", + "source": "ruby.observation", + "locator": "document", + "value": "{\u0022$schema\u0022:\u0022stellaops.ruby.observation@1\u0022,\u0022packages\u0022:[{\u0022name\u0022:\u0022bundler\u0022,\u0022version\u0022:\u00222.5.3\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022]},{\u0022name\u0022:\u0022pastel\u0022,\u0022version\u0022:\u00220.8.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022thor\u0022,\u0022version\u0022:\u00221.3.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-color\u0022,\u0022version\u0022:\u00220.6.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-cursor\u0022,\u0022version\u0022:\u00220.7.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-prompt\u0022,\u0022version\u0022:\u00220.23.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-reader\u0022,\u0022version\u0022:\u00220.9.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022tty-screen\u0022,\u0022version\u0022:\u00220.8.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022wisper\u0022,\u0022version\u0022:\u00222.0.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]}],\u0022entrypoints\u0022:[],\u0022dependencyEdges\u0022:[{\u0022from\u0022:\u0022pkg:gem/pastel@0.8.0\u0022,\u0022to\u0022:\u0022tty-color\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.5\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-prompt@0.23.1\u0022,\u0022to\u0022:\u0022pastel\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.8\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-prompt@0.23.1\u0022,\u0022to\u0022:\u0022tty-reader\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.8\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-reader@0.9.0\u0022,\u0022to\u0022:\u0022tty-cursor\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.7\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-reader@0.9.0\u0022,\u0022to\u0022:\u0022tty-screen\u0022,\u0022constraint\u0022:\u0022~\\u003E 0.8\u0022},{\u0022from\u0022:\u0022pkg:gem/tty-reader@0.9.0\u0022,\u0022to\u0022:\u0022wisper\u0022,\u0022constraint\u0022:\u0022~\\u003E 2.0\u0022}],\u0022runtimeEdges\u0022:[],\u0022environment\u0022:{\u0022bundlerVersion\u0022:\u00222.5.3\u0022,\u0022lockfiles\u0022:[\u0022Gemfile.lock\u0022]},\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:false,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[]},\u0022bundledWith\u0022:\u00222.5.3\u0022}", + "sha256": "sha256:5ec8b45dc480086cefbee03575845d57fb9fe4a0b000b109af46af5f2fe3f05d" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/bundler@2.5.3", + "purl": "pkg:gem/bundler@2.5.3", + "name": "bundler", + "version": "2.5.3", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "development", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/pastel@0.8.0", + "purl": "pkg:gem/pastel@0.8.0", + "name": "pastel", + "version": "0.8.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/thor@1.3.0", + "purl": "pkg:gem/thor@1.3.0", + "name": "thor", + "version": "1.3.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/tty-color@0.6.0", + "purl": "pkg:gem/tty-color@0.6.0", + "name": "tty-color", + "version": "0.6.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/tty-cursor@0.7.1", + "purl": "pkg:gem/tty-cursor@0.7.1", + "name": "tty-cursor", + "version": "0.7.1", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/tty-prompt@0.23.1", + "purl": "pkg:gem/tty-prompt@0.23.1", + "name": "tty-prompt", + "version": "0.23.1", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/tty-reader@0.9.0", + "purl": "pkg:gem/tty-reader@0.9.0", + "name": "tty-reader", + "version": "0.9.0", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/tty-screen@0.8.2", + "purl": "pkg:gem/tty-screen@0.8.2", + "name": "tty-screen", + "version": "0.8.2", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + }, + { + "analyzerId": "ruby", + "componentKey": "purl::pkg:gem/wisper@2.0.1", + "purl": "pkg:gem/wisper@2.0.1", + "name": "wisper", + "version": "2.0.1", + "type": "gem", + "usedByEntrypoint": false, + "metadata": { + "declaredOnly": "true", + "groups": "default", + "lockfile": "Gemfile.lock", + "source": "https://rubygems.org/" + }, + "evidence": [ + { + "kind": "file", + "source": "Gemfile.lock", + "locator": "Gemfile.lock" + } + ] + } +] \ No newline at end of file diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/complex-app/expected.json b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/complex-app/expected.json index f318a9112..68ba87e67 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/complex-app/expected.json +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/complex-app/expected.json @@ -12,6 +12,7 @@ "ruby.observation.capability.scheduler_list": "clockwork;sidekiq", "ruby.observation.capability.schedulers": "2", "ruby.observation.capability.serialization": "false", + "ruby.observation.dependency_edges": "4", "ruby.observation.packages": "6", "ruby.observation.runtime_edges": "5" }, @@ -20,8 +21,8 @@ "kind": "derived", "source": "ruby.observation", "locator": "document", - "value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022clockwork\u0022,\u0022version\u0022:\u00223.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022ops\u0022]},{\u0022name\u0022:\u0022pagy\u0022,\u0022version\u0022:\u00226.5.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022web\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00220.14.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022tools\u0022]},{\u0022name\u0022:\u0022rack\u0022,\u0022version\u0022:\u00223.1.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.1\u0022,\u0022source\u0022:\u0022vendor\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/custom-bundle/cache/sidekiq-7.2.1.gem\u0022,\u0022groups\u0022:[\u0022jobs\u0022]},{\u0022name\u0022:\u0022sinatra\u0022,\u0022version\u0022:\u00223.1.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/sinatra-3.1.0.gem\u0022,\u0022groups\u0022:[\u0022web\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022clockwork\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022pagy\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022,\u0022config/environment.rb\u0022],\u0022entrypoints\u0022:[\u0022config/environment.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rack\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sinatra\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[\u0022clockwork\u0022,\u0022sidekiq\u0022]},\u0022bundledWith\u0022:\u00222.5.3\u0022}", - "sha256": "sha256:beaefa12ec1f49e62343781ffa949ec3fa006f0452cf8a342a9a12be3cda1d82" + "value": "{\u0022$schema\u0022:\u0022stellaops.ruby.observation@1\u0022,\u0022packages\u0022:[{\u0022name\u0022:\u0022clockwork\u0022,\u0022version\u0022:\u00223.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022ops\u0022]},{\u0022name\u0022:\u0022pagy\u0022,\u0022version\u0022:\u00226.5.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022web\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00220.14.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022tools\u0022]},{\u0022name\u0022:\u0022rack\u0022,\u0022version\u0022:\u00223.1.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.1\u0022,\u0022source\u0022:\u0022vendor\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/custom-bundle/cache/sidekiq-7.2.1.gem\u0022,\u0022groups\u0022:[\u0022jobs\u0022]},{\u0022name\u0022:\u0022sinatra\u0022,\u0022version\u0022:\u00223.1.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/sinatra-3.1.0.gem\u0022,\u0022groups\u0022:[\u0022web\u0022]}],\u0022entrypoints\u0022:[{\u0022path\u0022:\u0022config/environment.rb\u0022,\u0022type\u0022:\u0022script\u0022,\u0022requiredGems\u0022:[\u0022pagy\u0022]}],\u0022dependencyEdges\u0022:[{\u0022from\u0022:\u0022pkg:gem/pry@0.14.2\u0022,\u0022to\u0022:\u0022coderay\u0022,\u0022constraint\u0022:\u0022~\\u003E 1.1\u0022},{\u0022from\u0022:\u0022pkg:gem/pry@0.14.2\u0022,\u0022to\u0022:\u0022method_source\u0022,\u0022constraint\u0022:\u0022~\\u003E 1.0\u0022},{\u0022from\u0022:\u0022pkg:gem/sidekiq@7.2.1\u0022,\u0022to\u0022:\u0022rack\u0022,\u0022constraint\u0022:\u0022~\\u003E 2.0\u0022},{\u0022from\u0022:\u0022pkg:gem/sinatra@3.1.0\u0022,\u0022to\u0022:\u0022rack\u0022,\u0022constraint\u0022:\u0022~\\u003E 3.0\u0022}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022clockwork\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022pagy\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022,\u0022config/environment.rb\u0022],\u0022entrypoints\u0022:[\u0022config/environment.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rack\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022scripts/worker.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sinatra\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022environment\u0022:{\u0022bundlerVersion\u0022:\u00222.5.3\u0022,\u0022bundlePaths\u0022:[\u0022/mnt/e/dev/git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/bin/Debug/net10.0/Fixtures/lang/ruby/complex-app/vendor/custom-bundle\u0022],\u0022gemfiles\u0022:[\u0022/mnt/e/dev/git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/bin/Debug/net10.0/Fixtures/lang/ruby/complex-app/Gemfile\u0022],\u0022lockfiles\u0022:[\u0022Gemfile.lock\u0022],\u0022frameworks\u0022:[\u0022clockwork\u0022,\u0022sidekiq\u0022]},\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[\u0022clockwork\u0022,\u0022sidekiq\u0022]},\u0022bundledWith\u0022:\u00222.5.3\u0022}", + "sha256": "sha256:58c8c02011baf8711e584a4b8e33effe7292a92af69cd6eaad6c3fd869ea93e0" } ] }, diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/simple-app/expected.json b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/simple-app/expected.json index 3225c8242..1571ccbfc 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/simple-app/expected.json +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/Fixtures/lang/ruby/simple-app/expected.json @@ -12,6 +12,7 @@ "ruby.observation.capability.scheduler_list": "sidekiq", "ruby.observation.capability.schedulers": "1", "ruby.observation.capability.serialization": "true", + "ruby.observation.dependency_edges": "4", "ruby.observation.packages": "11", "ruby.observation.runtime_edges": "3" }, @@ -20,8 +21,8 @@ "kind": "derived", "source": "ruby.observation", "locator": "document", - "value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022coderay\u0022,\u0022version\u0022:\u00221.1.3\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022connection_pool\u0022,\u0022version\u0022:\u00222.4.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022method_source\u0022,\u0022version\u0022:\u00221.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00220.14.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022,\u0022test\u0022]},{\u0022name\u0022:\u0022puma\u0022,\u0022version\u0022:\u00226.1.1\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/puma-6.1.1.gem\u0022,\u0022groups\u0022:[\u0022web\u0022]},{\u0022name\u0022:\u0022rack\u0022,\u0022version\u0022:\u00223.0.8\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rails\u0022,\u0022version\u0022:\u00227.1.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022platform\u0022:\u0022x86_64-linux\u0022,\u0022declaredOnly\u0022:false,\u0022artifact\u0022:\u0022vendor/cache/rails-7.1.0-x86_64-linux.gem\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rails\u0022,\u0022version\u0022:\u00227.1.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rspec\u0022,\u0022version\u0022:\u00223.12.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022test\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.1\u0022,\u0022source\u0022:\u0022vendor-bundle\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/bundle/ruby/3.1.0/gems/sidekiq-7.2.1\u0022,\u0022groups\u0022:[\u0022jobs\u0022]},{\u0022name\u0022:\u0022sqlite3\u0022,\u0022version\u0022:\u00221.6.0-x86_64-linux\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022db\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022puma\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app.rb\u0022],\u0022entrypoints\u0022:[\u0022app.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rack\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app.rb\u0022,\u0022config.ru\u0022],\u0022entrypoints\u0022:[\u0022app.rb\u0022,\u0022config.ru\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app.rb\u0022,\u0022app/workers/email_worker.rb\u0022],\u0022entrypoints\u0022:[\u0022app.rb\u0022,\u0022app/workers/email_worker.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:true,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:true,\u0022jobSchedulers\u0022:[\u0022sidekiq\u0022]},\u0022bundledWith\u0022:\u00222.4.22\u0022}", - "sha256": "sha256:30b34afcf1a3ae3a32f1088ca535ca5359f9ed1ecf53850909b2bcd4da663ace" + "value": "{\u0022$schema\u0022:\u0022stellaops.ruby.observation@1\u0022,\u0022packages\u0022:[{\u0022name\u0022:\u0022coderay\u0022,\u0022version\u0022:\u00221.1.3\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022connection_pool\u0022,\u0022version\u0022:\u00222.4.1\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022method_source\u0022,\u0022version\u0022:\u00221.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00220.14.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022,\u0022test\u0022]},{\u0022name\u0022:\u0022puma\u0022,\u0022version\u0022:\u00226.1.1\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/puma-6.1.1.gem\u0022,\u0022groups\u0022:[\u0022web\u0022]},{\u0022name\u0022:\u0022rack\u0022,\u0022version\u0022:\u00223.0.8\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rails\u0022,\u0022version\u0022:\u00227.1.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022platform\u0022:\u0022x86_64-linux\u0022,\u0022declaredOnly\u0022:false,\u0022artifact\u0022:\u0022vendor/cache/rails-7.1.0-x86_64-linux.gem\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rails\u0022,\u0022version\u0022:\u00227.1.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rspec\u0022,\u0022version\u0022:\u00223.12.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022test\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.1\u0022,\u0022source\u0022:\u0022vendor-bundle\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/bundle/ruby/3.1.0/gems/sidekiq-7.2.1\u0022,\u0022groups\u0022:[\u0022jobs\u0022]},{\u0022name\u0022:\u0022sqlite3\u0022,\u0022version\u0022:\u00221.6.0-x86_64-linux\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022db\u0022]}],\u0022entrypoints\u0022:[{\u0022path\u0022:\u0022app.rb\u0022,\u0022type\u0022:\u0022script\u0022,\u0022requiredGems\u0022:[\u0022puma\u0022,\u0022rack\u0022,\u0022sidekiq\u0022]},{\u0022path\u0022:\u0022app/workers/email_worker.rb\u0022,\u0022type\u0022:\u0022script\u0022,\u0022requiredGems\u0022:[\u0022sidekiq\u0022]},{\u0022path\u0022:\u0022config.ru\u0022,\u0022type\u0022:\u0022rack\u0022,\u0022requiredGems\u0022:[\u0022rack\u0022]},{\u0022path\u0022:\u0022config/clock.rb\u0022,\u0022type\u0022:\u0022script\u0022}],\u0022dependencyEdges\u0022:[{\u0022from\u0022:\u0022pkg:gem/pry@0.14.2\u0022,\u0022to\u0022:\u0022coderay\u0022,\u0022constraint\u0022:\u0022~\\u003E 1.1\u0022},{\u0022from\u0022:\u0022pkg:gem/pry@0.14.2\u0022,\u0022to\u0022:\u0022method_source\u0022,\u0022constraint\u0022:\u0022~\\u003E 1.0\u0022},{\u0022from\u0022:\u0022pkg:gem/sidekiq@7.2.1\u0022,\u0022to\u0022:\u0022connection_pool\u0022,\u0022constraint\u0022:\u0022\\u003E= 2.3.0\u0022},{\u0022from\u0022:\u0022pkg:gem/sidekiq@7.2.1\u0022,\u0022to\u0022:\u0022rack\u0022,\u0022constraint\u0022:\u0022~\\u003E 2.0\u0022}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022puma\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app.rb\u0022],\u0022entrypoints\u0022:[\u0022app.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rack\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app.rb\u0022,\u0022config.ru\u0022],\u0022entrypoints\u0022:[\u0022app.rb\u0022,\u0022config.ru\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app.rb\u0022,\u0022app/workers/email_worker.rb\u0022],\u0022entrypoints\u0022:[\u0022app.rb\u0022,\u0022app/workers/email_worker.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022environment\u0022:{\u0022bundlerVersion\u0022:\u00222.4.22\u0022,\u0022lockfiles\u0022:[\u0022Gemfile.lock\u0022],\u0022frameworks\u0022:[\u0022sidekiq\u0022]},\u0022capabilities\u0022:{\u0022usesExec\u0022:true,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:true,\u0022jobSchedulers\u0022:[\u0022sidekiq\u0022]},\u0022bundledWith\u0022:\u00222.4.22\u0022}", + "sha256": "sha256:09eefacbf4c46fba946a54bfeb3d68d1066836b0ea30f3bb66b864fc98ccae81" } ] }, diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/RubyLanguageAnalyzerTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/RubyLanguageAnalyzerTests.cs index 714db0cd9..d99871a84 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/RubyLanguageAnalyzerTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/RubyLanguageAnalyzerTests.cs @@ -86,4 +86,18 @@ public sealed class RubyLanguageAnalyzerTests analyzers, TestContext.Current.CancellationToken); } + + [Fact] + public async Task CliWorkspaceProducesDeterministicOutputAsync() + { + var fixturePath = TestPaths.ResolveFixture("lang", "ruby", "cli-app"); + var goldenPath = Path.Combine(fixturePath, "expected.json"); + var analyzers = new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() }; + + await LanguageAnalyzerTestHarness.AssertDeterministicAsync( + fixturePath, + goldenPath, + analyzers, + TestContext.Current.CancellationToken); + } } diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests.csproj b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests.csproj index 32da37dec..6d2f77e4e 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests.csproj +++ b/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests/StellaOps.Scanner.Analyzers.Lang.Ruby.Tests.csproj @@ -20,6 +20,8 @@ + + diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs index 9be8caf15..77b4cd71a 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceManifestStageExecutorTests.cs @@ -49,7 +49,8 @@ public sealed class SurfaceManifestStageExecutorTests metrics, NullLogger.Instance, hash, - new NullRubyPackageInventoryStore()); + new NullRubyPackageInventoryStore(), + new DeterminismContext(true, DateTimeOffset.Parse("2024-01-01T00:00:00Z"), 1337, true, 1)); var context = CreateContext(); @@ -86,7 +87,8 @@ public sealed class SurfaceManifestStageExecutorTests metrics, NullLogger.Instance, hash, - new NullRubyPackageInventoryStore()); + new NullRubyPackageInventoryStore(), + new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null)); var context = CreateContext(); PopulateAnalysis(context); @@ -121,6 +123,48 @@ public sealed class SurfaceManifestStageExecutorTests Assert.Contains(payloadMetrics, m => Equals("layer.fragments", m["surface.kind"])); } + [Fact] + public async Task ExecuteAsync_EmitsDeterminismPayload() + { + var metrics = new ScannerWorkerMetrics(); + var publisher = new TestSurfaceManifestPublisher("tenant-a"); + var cache = new RecordingSurfaceCache(); + var environment = new TestSurfaceEnvironment("tenant-a"); + var hash = CreateCryptoHash(); + var determinism = new DeterminismContext( + fixedClock: true, + fixedInstantUtc: DateTimeOffset.Parse("2024-01-01T00:00:00Z"), + rngSeed: 42, + filterLogs: true, + concurrencyLimit: 1); + + var executor = new SurfaceManifestStageExecutor( + publisher, + cache, + environment, + metrics, + NullLogger.Instance, + hash, + new NullRubyPackageInventoryStore(), + determinism); + + var context = CreateContext(); + context.Lease.Metadata["determinism.feed"] = "feed-001"; + context.Lease.Metadata["determinism.policy"] = "rev-77"; + + await executor.ExecuteAsync(context, CancellationToken.None); + + var determinismPayload = publisher.LastRequest!.Payloads.Single(p => p.Kind == "determinism.json"); + var json = JsonDocument.Parse(determinismPayload.Content.Span); + + Assert.True(json.RootElement.GetProperty("fixedClock").GetBoolean()); + Assert.Equal(42, json.RootElement.GetProperty("rngSeed").GetInt32()); + Assert.Equal(1, json.RootElement.GetProperty("concurrencyLimit").GetInt32()); + Assert.Equal("feed-001", json.RootElement.GetProperty("pins").GetProperty("feed").GetString()); + Assert.Equal("rev-77", json.RootElement.GetProperty("pins").GetProperty("policy").GetString()); + Assert.True(json.RootElement.GetProperty("artifacts").EnumerateObject().Any()); + } + [Fact] public async Task ExecuteAsync_IncludesEntropyPayloads_WhenPresent() { @@ -137,7 +181,8 @@ public sealed class SurfaceManifestStageExecutorTests metrics, NullLogger.Instance, hash, - new NullRubyPackageInventoryStore()); + new NullRubyPackageInventoryStore(), + new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null)); var context = CreateContext(); @@ -298,7 +343,8 @@ public sealed class SurfaceManifestStageExecutorTests metrics, NullLogger.Instance, hash, - new NullRubyPackageInventoryStore()); + new NullRubyPackageInventoryStore(), + new DeterminismContext(false, DateTimeOffset.UnixEpoch, null, false, null)); var context = CreateContext(); var observationBytes = Encoding.UTF8.GetBytes("{\"entrypoints\":[\"mod.ts\"]}"); diff --git a/src/Sdk/StellaOps.Sdk.Generator/TASKS.md b/src/Sdk/StellaOps.Sdk.Generator/TASKS.md index 7b676450b..de6443f36 100644 --- a/src/Sdk/StellaOps.Sdk.Generator/TASKS.md +++ b/src/Sdk/StellaOps.Sdk.Generator/TASKS.md @@ -4,7 +4,7 @@ | --- | --- | --- | | SDKGEN-62-001 | DONE (2025-11-24) | Toolchain pinned: OpenAPI Generator CLI 7.4.0 + JDK 21, determinism rules in TOOLCHAIN.md/toolchain.lock.yaml. | | SDKGEN-62-002 | DONE (2025-11-24) | Shared post-process now copies auth/retry/pagination/telemetry helpers for TS/Python/Go/Java, wires TS/Python exports, and adds smoke tests. | -| SDKGEN-63-001 | DOING (2025-11-24) | Added TS generator config/script, fixture spec, smoke test (green with vendored JDK/JAR); packaging templates and typed error/helper exports now copied via postprocess. Spec hash guard writes `.oas.sha256` and optionally enforces `STELLA_OAS_EXPECTED_SHA256`; waiting on frozen OpenAPI to publish alpha. | -| SDKGEN-63-002 | DOING (2025-11-24) | Python generator scaffold added (config, script, smoke test, reuse ping fixture) with spec hash guard + `.oas.sha256`; awaiting frozen OpenAPI to emit alpha. | +| SDKGEN-63-001 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to generate TS alpha; scaffold + smoke + hash guard ready. | +| SDKGEN-63-002 | BLOCKED (2025-11-26) | Waiting on frozen aggregate OAS digest to generate Python alpha; scaffold + smoke + hash guard ready. | | SDKGEN-63-003 | BLOCKED (2025-11-26) | Go generator scaffold ready; blocked on frozen aggregate OAS digest to emit alpha. | | SDKGEN-63-004 | BLOCKED (2025-11-26) | Java generator scaffold ready; blocked on frozen aggregate OAS digest to emit alpha. | diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 8bbe95cf4..1061a6527 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -57,6 +57,41 @@ export const routes: Routes = [ (m) => m.GraphExplorerComponent ), }, + { + path: 'evidence/:advisoryId', + loadComponent: () => + import('./features/evidence/evidence-page.component').then( + (m) => m.EvidencePageComponent + ), + }, + { + path: 'sources', + loadComponent: () => + import('./features/sources/aoc-dashboard.component').then( + (m) => m.AocDashboardComponent + ), + }, + { + path: 'sources/violations/:code', + loadComponent: () => + import('./features/sources/violation-detail.component').then( + (m) => m.ViolationDetailComponent + ), + }, + { + path: 'releases', + loadComponent: () => + import('./features/releases/release-flow.component').then( + (m) => m.ReleaseFlowComponent + ), + }, + { + path: 'releases/:releaseId', + loadComponent: () => + import('./features/releases/release-flow.component').then( + (m) => m.ReleaseFlowComponent + ), + }, { path: 'auth/callback', loadComponent: () => diff --git a/src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts b/src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts new file mode 100644 index 000000000..1e79a550c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts @@ -0,0 +1,364 @@ +import { Injectable, InjectionToken } from '@angular/core'; +import { Observable, of, delay } from 'rxjs'; +import { + AocDashboardSummary, + AocPassFailSummary, + AocViolationCode, + IngestThroughput, + AocSource, + AocCheckResult, + VerificationRequest, + ViolationDetail, + TimeSeriesPoint, +} from './aoc.models'; + +/** + * Injection token for AOC API client. + */ +export const AOC_API = new InjectionToken('AOC_API'); + +/** + * AOC API interface. + */ +export interface AocApi { + getDashboardSummary(): Observable; + getViolationDetail(violationId: string): Observable; + getViolationsByCode(code: string): Observable; + startVerification(): Observable; + getVerificationStatus(requestId: string): Observable; +} + +// ============================================================================ +// Mock Data Fixtures +// ============================================================================ + +function generateHistory(days: number, baseValue: number, variance: number): TimeSeriesPoint[] { + const points: TimeSeriesPoint[] = []; + const now = new Date(); + for (let i = days - 1; i >= 0; i--) { + const date = new Date(now); + date.setDate(date.getDate() - i); + points.push({ + timestamp: date.toISOString(), + value: baseValue + Math.floor(Math.random() * variance * 2) - variance, + }); + } + return points; +} + +const mockPassFailSummary: AocPassFailSummary = { + period: 'last_24h', + totalChecks: 1247, + passed: 1198, + failed: 32, + pending: 12, + skipped: 5, + passRate: 0.961, + trend: 'improving', + history: generateHistory(7, 96, 3), +}; + +const mockViolationCodes: AocViolationCode[] = [ + { + code: 'AOC-001', + name: 'Missing Provenance', + severity: 'critical', + description: 'Document lacks required provenance attestation', + count: 12, + lastSeen: '2025-11-27T09:45:00Z', + documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-001', + }, + { + code: 'AOC-002', + name: 'Invalid Signature', + severity: 'critical', + description: 'Document signature verification failed', + count: 8, + lastSeen: '2025-11-27T08:30:00Z', + documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-002', + }, + { + code: 'AOC-010', + name: 'Schema Mismatch', + severity: 'high', + description: 'Document does not conform to expected schema version', + count: 5, + lastSeen: '2025-11-27T07:15:00Z', + documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-010', + }, + { + code: 'AOC-015', + name: 'Timestamp Drift', + severity: 'medium', + description: 'Document timestamp exceeds allowed drift threshold', + count: 4, + lastSeen: '2025-11-27T06:00:00Z', + }, + { + code: 'AOC-020', + name: 'Metadata Incomplete', + severity: 'low', + description: 'Optional metadata fields are missing', + count: 3, + lastSeen: '2025-11-26T22:30:00Z', + }, +]; + +const mockThroughput: IngestThroughput[] = [ + { + tenantId: 'tenant-001', + tenantName: 'Acme Corp', + documentsIngested: 15420, + bytesIngested: 2_450_000_000, + documentsPerMinute: 10.7, + bytesPerMinute: 1_701_388, + period: 'last_24h', + }, + { + tenantId: 'tenant-002', + tenantName: 'TechStart Inc', + documentsIngested: 8932, + bytesIngested: 1_120_000_000, + documentsPerMinute: 6.2, + bytesPerMinute: 777_777, + period: 'last_24h', + }, + { + tenantId: 'tenant-003', + tenantName: 'DataFlow Ltd', + documentsIngested: 5678, + bytesIngested: 890_000_000, + documentsPerMinute: 3.9, + bytesPerMinute: 618_055, + period: 'last_24h', + }, + { + tenantId: 'tenant-004', + tenantName: 'SecureOps', + documentsIngested: 3421, + bytesIngested: 456_000_000, + documentsPerMinute: 2.4, + bytesPerMinute: 316_666, + period: 'last_24h', + }, +]; + +const mockSources: AocSource[] = [ + { + sourceId: 'src-001', + name: 'Production Registry', + type: 'registry', + status: 'passed', + lastCheck: '2025-11-27T10:00:00Z', + checkCount: 523, + passRate: 0.98, + recentViolations: [], + }, + { + sourceId: 'src-002', + name: 'GitHub Actions Pipeline', + type: 'pipeline', + status: 'failed', + lastCheck: '2025-11-27T09:45:00Z', + checkCount: 412, + passRate: 0.92, + recentViolations: [mockViolationCodes[0], mockViolationCodes[1]], + }, + { + sourceId: 'src-003', + name: 'Staging Registry', + type: 'registry', + status: 'passed', + lastCheck: '2025-11-27T09:30:00Z', + checkCount: 201, + passRate: 0.995, + recentViolations: [], + }, + { + sourceId: 'src-004', + name: 'Manual Upload', + type: 'manual', + status: 'pending', + lastCheck: '2025-11-27T08:00:00Z', + checkCount: 111, + passRate: 0.85, + recentViolations: [mockViolationCodes[2]], + }, +]; + +const mockRecentChecks: AocCheckResult[] = [ + { + checkId: 'chk-001', + documentId: 'doc-abc123', + documentType: 'sbom', + status: 'passed', + checkedAt: '2025-11-27T10:00:00Z', + violations: [], + sourceId: 'src-001', + tenantId: 'tenant-001', + }, + { + checkId: 'chk-002', + documentId: 'doc-def456', + documentType: 'attestation', + status: 'failed', + checkedAt: '2025-11-27T09:55:00Z', + violations: [mockViolationCodes[0]], + sourceId: 'src-002', + tenantId: 'tenant-001', + }, + { + checkId: 'chk-003', + documentId: 'doc-ghi789', + documentType: 'sbom', + status: 'passed', + checkedAt: '2025-11-27T09:50:00Z', + violations: [], + sourceId: 'src-001', + tenantId: 'tenant-002', + }, + { + checkId: 'chk-004', + documentId: 'doc-jkl012', + documentType: 'provenance', + status: 'failed', + checkedAt: '2025-11-27T09:45:00Z', + violations: [mockViolationCodes[1]], + sourceId: 'src-002', + tenantId: 'tenant-001', + }, + { + checkId: 'chk-005', + documentId: 'doc-mno345', + documentType: 'sbom', + status: 'pending', + checkedAt: '2025-11-27T09:40:00Z', + violations: [], + sourceId: 'src-004', + tenantId: 'tenant-003', + }, +]; + +const mockDashboard: AocDashboardSummary = { + generatedAt: new Date().toISOString(), + passFail: mockPassFailSummary, + recentViolations: mockViolationCodes, + throughputByTenant: mockThroughput, + sources: mockSources, + recentChecks: mockRecentChecks, +}; + +const mockViolationDetails: ViolationDetail[] = [ + { + violationId: 'viol-001', + code: 'AOC-001', + severity: 'critical', + documentId: 'doc-def456', + documentType: 'attestation', + offendingFields: [ + { + path: '$.predicate.buildType', + expectedValue: 'https://slsa.dev/provenance/v1', + actualValue: undefined, + reason: 'Required field is missing', + }, + { + path: '$.predicate.builder.id', + expectedValue: 'https://github.com/actions/runner', + actualValue: undefined, + reason: 'Builder ID not specified', + }, + ], + provenance: { + sourceType: 'pipeline', + sourceUri: 'github.com/acme/api-service', + ingestedAt: '2025-11-27T09:55:00Z', + ingestedBy: 'github-actions', + buildId: 'build-12345', + commitSha: 'a1b2c3d4e5f6', + pipelineUrl: 'https://github.com/acme/api-service/actions/runs/12345', + }, + detectedAt: '2025-11-27T09:55:00Z', + suggestion: 'Add SLSA provenance attestation to your build pipeline. See https://slsa.dev/spec/v1.0/provenance', + }, + { + violationId: 'viol-002', + code: 'AOC-002', + severity: 'critical', + documentId: 'doc-jkl012', + documentType: 'provenance', + offendingFields: [ + { + path: '$.signatures[0]', + expectedValue: 'Valid DSSE signature', + actualValue: 'Invalid or expired signature', + reason: 'Signature verification failed: key not found in keyring', + }, + ], + provenance: { + sourceType: 'pipeline', + sourceUri: 'github.com/acme/worker-service', + ingestedAt: '2025-11-27T09:45:00Z', + ingestedBy: 'github-actions', + buildId: 'build-12346', + commitSha: 'b2c3d4e5f6a7', + pipelineUrl: 'https://github.com/acme/worker-service/actions/runs/12346', + }, + detectedAt: '2025-11-27T09:45:00Z', + suggestion: 'Ensure the signing key is registered in your tenant keyring. Run: stella keys add --public-key ', + }, +]; + +// ============================================================================ +// Mock API Implementation +// ============================================================================ + +@Injectable({ providedIn: 'root' }) +export class MockAocApi implements AocApi { + getDashboardSummary(): Observable { + return of({ + ...mockDashboard, + generatedAt: new Date().toISOString(), + }).pipe(delay(300)); + } + + getViolationDetail(violationId: string): Observable { + const detail = mockViolationDetails.find((v) => v.violationId === violationId); + if (!detail) { + throw new Error(`Violation not found: ${violationId}`); + } + return of(detail).pipe(delay(200)); + } + + getViolationsByCode(code: string): Observable { + const details = mockViolationDetails.filter((v) => v.code === code); + return of(details).pipe(delay(250)); + } + + startVerification(): Observable { + return of({ + requestId: `verify-${Date.now()}`, + status: 'queued', + documentsToVerify: 1247, + documentsVerified: 0, + passed: 0, + failed: 0, + cliCommand: 'stella aoc verify --since 24h --output json', + }).pipe(delay(400)); + } + + getVerificationStatus(requestId: string): Observable { + // Simulate a completed verification + return of({ + requestId, + status: 'completed', + startedAt: new Date(Date.now() - 120000).toISOString(), + completedAt: new Date().toISOString(), + documentsToVerify: 1247, + documentsVerified: 1247, + passed: 1198, + failed: 49, + cliCommand: 'stella aoc verify --since 24h --output json', + }).pipe(delay(300)); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts b/src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts new file mode 100644 index 000000000..78a406cc5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts @@ -0,0 +1,152 @@ +/** + * Attestation of Conformance (AOC) models for UI-AOC-19-001. + * Supports Sources dashboard tiles showing pass/fail, violation codes, and ingest throughput. + */ + +// AOC verification status +export type AocVerificationStatus = 'passed' | 'failed' | 'pending' | 'skipped'; + +// Violation severity levels +export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info'; + +/** + * AOC violation code with metadata. + */ +export interface AocViolationCode { + readonly code: string; + readonly name: string; + readonly severity: ViolationSeverity; + readonly description: string; + readonly count: number; + readonly lastSeen: string; + readonly documentationUrl?: string; +} + +/** + * Per-tenant ingest throughput metrics. + */ +export interface IngestThroughput { + readonly tenantId: string; + readonly tenantName: string; + readonly documentsIngested: number; + readonly bytesIngested: number; + readonly documentsPerMinute: number; + readonly bytesPerMinute: number; + readonly period: string; // e.g., "last_24h", "last_7d" +} + +/** + * Time-series data point for charts. + */ +export interface TimeSeriesPoint { + readonly timestamp: string; + readonly value: number; +} + +/** + * AOC pass/fail summary for a time period. + */ +export interface AocPassFailSummary { + readonly period: string; + readonly totalChecks: number; + readonly passed: number; + readonly failed: number; + readonly pending: number; + readonly skipped: number; + readonly passRate: number; // 0-1 + readonly trend: 'improving' | 'stable' | 'degrading'; + readonly history: readonly TimeSeriesPoint[]; +} + +/** + * Individual AOC check result. + */ +export interface AocCheckResult { + readonly checkId: string; + readonly documentId: string; + readonly documentType: string; + readonly status: AocVerificationStatus; + readonly checkedAt: string; + readonly violations: readonly AocViolationCode[]; + readonly sourceId?: string; + readonly tenantId: string; +} + +/** + * Source with AOC metrics. + */ +export interface AocSource { + readonly sourceId: string; + readonly name: string; + readonly type: 'registry' | 'repository' | 'pipeline' | 'manual'; + readonly status: AocVerificationStatus; + readonly lastCheck: string; + readonly checkCount: number; + readonly passRate: number; + readonly recentViolations: readonly AocViolationCode[]; +} + +/** + * AOC dashboard summary combining all metrics. + */ +export interface AocDashboardSummary { + readonly generatedAt: string; + readonly passFail: AocPassFailSummary; + readonly recentViolations: readonly AocViolationCode[]; + readonly throughputByTenant: readonly IngestThroughput[]; + readonly sources: readonly AocSource[]; + readonly recentChecks: readonly AocCheckResult[]; +} + +/** + * Verification request for "Verify last 24h" action. + */ +export interface VerificationRequest { + readonly requestId: string; + readonly status: 'queued' | 'running' | 'completed' | 'failed'; + readonly startedAt?: string; + readonly completedAt?: string; + readonly documentsToVerify: number; + readonly documentsVerified: number; + readonly passed: number; + readonly failed: number; + readonly cliCommand?: string; // CLI parity command +} + +/** + * Violation detail for drill-down view. + */ +export interface ViolationDetail { + readonly violationId: string; + readonly code: string; + readonly severity: ViolationSeverity; + readonly documentId: string; + readonly documentType: string; + readonly offendingFields: readonly OffendingField[]; + readonly provenance: ProvenanceMetadata; + readonly detectedAt: string; + readonly suggestion?: string; +} + +/** + * Offending field in a document. + */ +export interface OffendingField { + readonly path: string; // JSON path, e.g., "$.metadata.labels" + readonly expectedValue?: string; + readonly actualValue?: string; + readonly reason: string; +} + +/** + * Provenance metadata for a document. + */ +export interface ProvenanceMetadata { + readonly sourceType: string; + readonly sourceUri: string; + readonly ingestedAt: string; + readonly ingestedBy: string; + readonly buildId?: string; + readonly commitSha?: string; + readonly pipelineUrl?: string; +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts b/src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts new file mode 100644 index 000000000..3d367ef94 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts @@ -0,0 +1,323 @@ +import { Injectable, InjectionToken } from '@angular/core'; +import { Observable, of, delay } from 'rxjs'; + +import { + EvidenceData, + Linkset, + Observation, + PolicyEvidence, +} from './evidence.models'; + +export interface EvidenceApi { + getEvidenceForAdvisory(advisoryId: string): Observable; + getObservation(observationId: string): Observable; + getLinkset(linksetId: string): Observable; + getPolicyEvidence(advisoryId: string): Observable; + downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable; +} + +export const EVIDENCE_API = new InjectionToken('EVIDENCE_API'); + +// Mock data for development +const MOCK_OBSERVATIONS: Observation[] = [ + { + observationId: 'obs-ghsa-001', + tenantId: 'tenant-1', + source: 'ghsa', + advisoryId: 'GHSA-jfh8-c2jp-5v3q', + title: 'Log4j Remote Code Execution (Log4Shell)', + summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.', + severities: [ + { system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' }, + ], + affected: [ + { + purl: 'pkg:maven/org.apache.logging.log4j/log4j-core', + package: 'log4j-core', + ecosystem: 'maven', + ranges: [ + { + type: 'ECOSYSTEM', + events: [ + { introduced: '2.0-beta9' }, + { fixed: '2.15.0' }, + ], + }, + ], + }, + ], + references: [ + 'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q', + 'https://logging.apache.org/log4j/2.x/security.html', + ], + weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'], + published: '2021-12-10T00:00:00Z', + modified: '2024-01-15T10:30:00Z', + provenance: { + sourceArtifactSha: 'sha256:abc123def456...', + fetchedAt: '2024-11-20T08:00:00Z', + ingestJobId: 'job-ghsa-2024-1120', + }, + ingestedAt: '2024-11-20T08:05:00Z', + }, + { + observationId: 'obs-nvd-001', + tenantId: 'tenant-1', + source: 'nvd', + advisoryId: 'CVE-2021-44228', + title: 'Apache Log4j2 Remote Code Execution Vulnerability', + summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.', + severities: [ + { system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' }, + { system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' }, + ], + affected: [ + { + purl: 'pkg:maven/org.apache.logging.log4j/log4j-core', + package: 'log4j-core', + ecosystem: 'maven', + versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'], + cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'], + }, + ], + references: [ + 'https://nvd.nist.gov/vuln/detail/CVE-2021-44228', + 'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance', + ], + relationships: [ + { type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' }, + ], + weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'], + published: '2021-12-10T10:15:00Z', + modified: '2024-02-20T15:45:00Z', + provenance: { + sourceArtifactSha: 'sha256:def789ghi012...', + fetchedAt: '2024-11-20T08:10:00Z', + ingestJobId: 'job-nvd-2024-1120', + }, + ingestedAt: '2024-11-20T08:15:00Z', + }, + { + observationId: 'obs-osv-001', + tenantId: 'tenant-1', + source: 'osv', + advisoryId: 'GHSA-jfh8-c2jp-5v3q', + title: 'Remote code injection in Log4j', + summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.', + severities: [ + { system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' }, + ], + affected: [ + { + purl: 'pkg:maven/org.apache.logging.log4j/log4j-core', + package: 'log4j-core', + ecosystem: 'Maven', + ranges: [ + { + type: 'ECOSYSTEM', + events: [ + { introduced: '2.0-beta9' }, + { fixed: '2.3.1' }, + ], + }, + { + type: 'ECOSYSTEM', + events: [ + { introduced: '2.4' }, + { fixed: '2.12.2' }, + ], + }, + { + type: 'ECOSYSTEM', + events: [ + { introduced: '2.13.0' }, + { fixed: '2.15.0' }, + ], + }, + ], + }, + ], + references: [ + 'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q', + ], + published: '2021-12-10T00:00:00Z', + modified: '2023-06-15T09:00:00Z', + provenance: { + sourceArtifactSha: 'sha256:ghi345jkl678...', + fetchedAt: '2024-11-20T08:20:00Z', + ingestJobId: 'job-osv-2024-1120', + }, + ingestedAt: '2024-11-20T08:25:00Z', + }, +]; + +const MOCK_LINKSET: Linkset = { + linksetId: 'ls-log4shell-001', + tenantId: 'tenant-1', + advisoryId: 'CVE-2021-44228', + source: 'aggregated', + observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'], + normalized: { + purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'], + versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'], + severities: [ + { system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' }, + ], + }, + confidence: 0.95, + conflicts: [ + { + field: 'affected.ranges', + reason: 'Different fixed version ranges reported by sources', + values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'], + sourceIds: ['ghsa', 'osv'], + }, + { + field: 'weaknesses', + reason: 'Different CWE identifiers reported', + values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'], + sourceIds: ['ghsa', 'nvd'], + }, + ], + createdAt: '2024-11-20T08:30:00Z', + builtByJobId: 'linkset-build-2024-1120', + provenance: { + observationHashes: [ + 'sha256:abc123...', + 'sha256:def789...', + 'sha256:ghi345...', + ], + toolVersion: 'concelier-lnm-1.2.0', + policyHash: 'sha256:policy-hash-001', + }, +}; + +const MOCK_POLICY_EVIDENCE: PolicyEvidence = { + policyId: 'pol-critical-vuln-001', + policyName: 'Critical Vulnerability Policy', + decision: 'block', + decidedAt: '2024-11-20T08:35:00Z', + reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits', + rules: [ + { + ruleId: 'rule-cvss-critical', + ruleName: 'Block Critical CVSS', + passed: false, + reason: 'CVSS score 10.0 exceeds threshold of 9.0', + matchedItems: ['CVE-2021-44228'], + }, + { + ruleId: 'rule-known-exploit', + ruleName: 'Known Exploit Check', + passed: false, + reason: 'Active exploitation reported by CISA', + matchedItems: ['KEV-2021-44228'], + }, + { + ruleId: 'rule-fix-available', + ruleName: 'Fix Available', + passed: true, + reason: 'Fixed version 2.15.0+ available', + }, + ], + linksetIds: ['ls-log4shell-001'], + aocChain: [ + { + attestationId: 'aoc-obs-ghsa-001', + type: 'observation', + hash: 'sha256:abc123def456...', + timestamp: '2024-11-20T08:05:00Z', + parentHash: undefined, + }, + { + attestationId: 'aoc-obs-nvd-001', + type: 'observation', + hash: 'sha256:def789ghi012...', + timestamp: '2024-11-20T08:15:00Z', + parentHash: 'sha256:abc123def456...', + }, + { + attestationId: 'aoc-obs-osv-001', + type: 'observation', + hash: 'sha256:ghi345jkl678...', + timestamp: '2024-11-20T08:25:00Z', + parentHash: 'sha256:def789ghi012...', + }, + { + attestationId: 'aoc-ls-001', + type: 'linkset', + hash: 'sha256:linkset-hash-001...', + timestamp: '2024-11-20T08:30:00Z', + parentHash: 'sha256:ghi345jkl678...', + }, + { + attestationId: 'aoc-policy-001', + type: 'policy', + hash: 'sha256:policy-decision-hash...', + timestamp: '2024-11-20T08:35:00Z', + signer: 'policy-engine-v1', + parentHash: 'sha256:linkset-hash-001...', + }, + ], +}; + +@Injectable({ providedIn: 'root' }) +export class MockEvidenceApiService implements EvidenceApi { + getEvidenceForAdvisory(advisoryId: string): Observable { + // Filter observations related to the advisory + const observations = MOCK_OBSERVATIONS.filter( + (o) => + o.advisoryId === advisoryId || + o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228 + ); + + const linkset = MOCK_LINKSET; + const policyEvidence = MOCK_POLICY_EVIDENCE; + + const data: EvidenceData = { + advisoryId, + title: observations[0]?.title ?? `Evidence for ${advisoryId}`, + observations, + linkset, + policyEvidence, + hasConflicts: linkset.conflicts.length > 0, + conflictCount: linkset.conflicts.length, + }; + + return of(data).pipe(delay(300)); + } + + getObservation(observationId: string): Observable { + const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId); + if (!observation) { + throw new Error(`Observation not found: ${observationId}`); + } + return of(observation).pipe(delay(100)); + } + + getLinkset(linksetId: string): Observable { + if (linksetId === MOCK_LINKSET.linksetId) { + return of(MOCK_LINKSET).pipe(delay(100)); + } + throw new Error(`Linkset not found: ${linksetId}`); + } + + getPolicyEvidence(advisoryId: string): Observable { + if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') { + return of(MOCK_POLICY_EVIDENCE).pipe(delay(100)); + } + return of(null).pipe(delay(100)); + } + + downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable { + let data: object; + if (type === 'observation') { + data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {}; + } else { + data = MOCK_LINKSET; + } + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + return of(blob).pipe(delay(100)); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts b/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts new file mode 100644 index 000000000..19f010175 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts @@ -0,0 +1,189 @@ +/** + * Link-Not-Merge Evidence Models + * Based on docs/modules/concelier/link-not-merge-schema.md + */ + +// Severity from advisory sources +export interface AdvisorySeverity { + readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa' + readonly score: number; + readonly vector?: string; +} + +// Affected package information +export interface AffectedPackage { + readonly purl: string; + readonly package?: string; + readonly versions?: readonly string[]; + readonly ranges?: readonly VersionRange[]; + readonly ecosystem?: string; + readonly cpe?: readonly string[]; +} + +export interface VersionRange { + readonly type: string; + readonly events: readonly VersionEvent[]; +} + +export interface VersionEvent { + readonly introduced?: string; + readonly fixed?: string; + readonly last_affected?: string; +} + +// Relationship between advisories +export interface AdvisoryRelationship { + readonly type: string; + readonly source: string; + readonly target: string; + readonly provenance?: string; +} + +// Provenance tracking +export interface ObservationProvenance { + readonly sourceArtifactSha: string; + readonly fetchedAt: string; + readonly ingestJobId?: string; + readonly signature?: Record; +} + +// Raw observation from a single source +export interface Observation { + readonly observationId: string; + readonly tenantId: string; + readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund' + readonly advisoryId: string; + readonly title?: string; + readonly summary?: string; + readonly severities: readonly AdvisorySeverity[]; + readonly affected: readonly AffectedPackage[]; + readonly references?: readonly string[]; + readonly scopes?: readonly string[]; + readonly relationships?: readonly AdvisoryRelationship[]; + readonly weaknesses?: readonly string[]; + readonly published?: string; + readonly modified?: string; + readonly provenance: ObservationProvenance; + readonly ingestedAt: string; +} + +// Conflict when sources disagree +export interface LinksetConflict { + readonly field: string; + readonly reason: string; + readonly values?: readonly string[]; + readonly sourceIds?: readonly string[]; +} + +// Linkset provenance +export interface LinksetProvenance { + readonly observationHashes: readonly string[]; + readonly toolVersion?: string; + readonly policyHash?: string; +} + +// Normalized linkset aggregating multiple observations +export interface Linkset { + readonly linksetId: string; + readonly tenantId: string; + readonly advisoryId: string; + readonly source: string; + readonly observations: readonly string[]; // observation IDs + readonly normalized?: { + readonly purls?: readonly string[]; + readonly versions?: readonly string[]; + readonly ranges?: readonly VersionRange[]; + readonly severities?: readonly AdvisorySeverity[]; + }; + readonly confidence?: number; // 0-1 + readonly conflicts: readonly LinksetConflict[]; + readonly createdAt: string; + readonly builtByJobId?: string; + readonly provenance?: LinksetProvenance; +} + +// Policy decision result +export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending'; + +// Policy decision with evidence +export interface PolicyEvidence { + readonly policyId: string; + readonly policyName: string; + readonly decision: PolicyDecision; + readonly decidedAt: string; + readonly reason?: string; + readonly rules: readonly PolicyRuleResult[]; + readonly linksetIds: readonly string[]; + readonly aocChain?: AocChainEntry[]; +} + +export interface PolicyRuleResult { + readonly ruleId: string; + readonly ruleName: string; + readonly passed: boolean; + readonly reason?: string; + readonly matchedItems?: readonly string[]; +} + +// AOC (Attestation of Compliance) chain entry +export interface AocChainEntry { + readonly attestationId: string; + readonly type: 'observation' | 'linkset' | 'policy' | 'signature'; + readonly hash: string; + readonly timestamp: string; + readonly signer?: string; + readonly parentHash?: string; +} + +// Evidence panel data combining all elements +export interface EvidenceData { + readonly advisoryId: string; + readonly title?: string; + readonly observations: readonly Observation[]; + readonly linkset?: Linkset; + readonly policyEvidence?: PolicyEvidence; + readonly hasConflicts: boolean; + readonly conflictCount: number; +} + +// Source metadata for display +export interface SourceInfo { + readonly sourceId: string; + readonly name: string; + readonly icon?: string; + readonly url?: string; + readonly lastUpdated?: string; +} + +export const SOURCE_INFO: Record = { + ghsa: { + sourceId: 'ghsa', + name: 'GitHub Security Advisories', + icon: 'github', + url: 'https://github.com/advisories', + }, + nvd: { + sourceId: 'nvd', + name: 'National Vulnerability Database', + icon: 'database', + url: 'https://nvd.nist.gov', + }, + 'cert-bund': { + sourceId: 'cert-bund', + name: 'CERT-Bund', + icon: 'shield', + url: 'https://www.cert-bund.de', + }, + osv: { + sourceId: 'osv', + name: 'Open Source Vulnerabilities', + icon: 'box', + url: 'https://osv.dev', + }, + cve: { + sourceId: 'cve', + name: 'CVE Program', + icon: 'alert-triangle', + url: 'https://cve.mitre.org', + }, +}; diff --git a/src/Web/StellaOps.Web/src/app/core/api/release.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release.client.ts new file mode 100644 index 000000000..3f500409c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/release.client.ts @@ -0,0 +1,373 @@ +import { Injectable, InjectionToken } from '@angular/core'; +import { Observable, of, delay } from 'rxjs'; +import { + Release, + ReleaseArtifact, + PolicyEvaluation, + PolicyGateResult, + DeterminismGateDetails, + RemediationHint, + DeterminismFeatureFlags, + PolicyGateStatus, +} from './release.models'; + +/** + * Injection token for Release API client. + */ +export const RELEASE_API = new InjectionToken('RELEASE_API'); + +/** + * Release API interface. + */ +export interface ReleaseApi { + getRelease(releaseId: string): Observable; + listReleases(): Observable; + publishRelease(releaseId: string): Observable; + cancelRelease(releaseId: string): Observable; + getFeatureFlags(): Observable; + requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>; +} + +// ============================================================================ +// Mock Data Fixtures +// ============================================================================ + +const determinismPassingGate: PolicyGateResult = { + gateId: 'gate-det-001', + gateType: 'determinism', + name: 'SBOM Determinism', + status: 'passed', + message: 'Merkle root consistent. All fragment attestations verified.', + evaluatedAt: '2025-11-27T10:15:00Z', + blockingPublish: true, + evidence: { + type: 'determinism', + url: '/scans/scan-abc123?tab=determinism', + details: { + merkleRoot: 'sha256:a1b2c3d4e5f6...', + fragmentCount: 8, + verifiedFragments: 8, + }, + }, +}; + +const determinismFailingGate: PolicyGateResult = { + gateId: 'gate-det-002', + gateType: 'determinism', + name: 'SBOM Determinism', + status: 'failed', + message: 'Merkle root mismatch. 2 fragment attestations failed verification.', + evaluatedAt: '2025-11-27T09:30:00Z', + blockingPublish: true, + evidence: { + type: 'determinism', + url: '/scans/scan-def456?tab=determinism', + details: { + merkleRoot: 'sha256:f1e2d3c4b5a6...', + expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...', + fragmentCount: 8, + verifiedFragments: 6, + failedFragments: [ + 'sha256:layer3digest...', + 'sha256:layer5digest...', + ], + }, + }, + remediation: { + gateType: 'determinism', + severity: 'critical', + summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.', + steps: [ + { + action: 'rebuild', + title: 'Rebuild with deterministic toolchain', + description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.', + command: 'stella scan --deterministic --sign --push', + documentationUrl: 'https://docs.stellaops.io/scanner/determinism', + automated: false, + }, + { + action: 'provide-provenance', + title: 'Provide provenance attestation', + description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.', + documentationUrl: 'https://docs.stellaops.io/provenance', + automated: false, + }, + { + action: 'sign-artifact', + title: 'Re-sign with valid key', + description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.', + command: 'stella sign --artifact sha256:...', + automated: true, + }, + { + action: 'request-exception', + title: 'Request policy exception', + description: 'If this is a known issue with a compensating control, request a time-boxed exception.', + automated: true, + }, + ], + estimatedEffort: '15-30 minutes', + exceptionAllowed: true, + }, +}; + +const vulnerabilityPassingGate: PolicyGateResult = { + gateId: 'gate-vuln-001', + gateType: 'vulnerability', + name: 'Vulnerability Scan', + status: 'passed', + message: 'No critical or high vulnerabilities. 3 medium, 12 low.', + evaluatedAt: '2025-11-27T10:15:00Z', + blockingPublish: false, +}; + +const entropyWarningGate: PolicyGateResult = { + gateId: 'gate-ent-001', + gateType: 'entropy', + name: 'Entropy Analysis', + status: 'warning', + message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.', + evaluatedAt: '2025-11-27T10:15:00Z', + blockingPublish: false, + remediation: { + gateType: 'entropy', + severity: 'medium', + summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.', + steps: [ + { + action: 'provide-provenance', + title: 'Provide source provenance', + description: 'Attach build provenance or source mappings for high-entropy binaries.', + automated: false, + }, + ], + estimatedEffort: '10 minutes', + exceptionAllowed: true, + }, +}; + +const licensePassingGate: PolicyGateResult = { + gateId: 'gate-lic-001', + gateType: 'license', + name: 'License Compliance', + status: 'passed', + message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.', + evaluatedAt: '2025-11-27T10:15:00Z', + blockingPublish: false, +}; + +const signaturePassingGate: PolicyGateResult = { + gateId: 'gate-sig-001', + gateType: 'signature', + name: 'Signature Verification', + status: 'passed', + message: 'Image signature verified against tenant keyring.', + evaluatedAt: '2025-11-27T10:15:00Z', + blockingPublish: true, +}; + +const signatureFailingGate: PolicyGateResult = { + gateId: 'gate-sig-002', + gateType: 'signature', + name: 'Signature Verification', + status: 'failed', + message: 'No valid signature found. Image must be signed before release.', + evaluatedAt: '2025-11-27T09:30:00Z', + blockingPublish: true, + remediation: { + gateType: 'signature', + severity: 'critical', + summary: 'The image is not signed or the signature cannot be verified.', + steps: [ + { + action: 'sign-artifact', + title: 'Sign the image', + description: 'Sign the image using your tenant signing key.', + command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3', + automated: true, + }, + ], + estimatedEffort: '2 minutes', + exceptionAllowed: false, + }, +}; + +// Artifacts with policy evaluations +const passingArtifact: ReleaseArtifact = { + artifactId: 'art-001', + name: 'api-service', + tag: 'v1.2.3', + digest: 'sha256:abc123def456789012345678901234567890abcdef', + size: 245_000_000, + createdAt: '2025-11-27T08:00:00Z', + registry: 'registry.stellaops.io/prod', + policyEvaluation: { + evaluationId: 'eval-001', + artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef', + evaluatedAt: '2025-11-27T10:15:00Z', + overallStatus: 'passed', + gates: [ + determinismPassingGate, + vulnerabilityPassingGate, + entropyWarningGate, + licensePassingGate, + signaturePassingGate, + ], + blockingGates: [], + canPublish: true, + determinismDetails: { + merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321', + merkleRootConsistent: true, + contentHash: 'sha256:content1234567890abcdef', + compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json', + fragmentCount: 8, + verifiedFragments: 8, + }, + }, +}; + +const failingArtifact: ReleaseArtifact = { + artifactId: 'art-002', + name: 'worker-service', + tag: 'v1.2.3', + digest: 'sha256:def456abc789012345678901234567890fedcba98', + size: 312_000_000, + createdAt: '2025-11-27T07:45:00Z', + registry: 'registry.stellaops.io/prod', + policyEvaluation: { + evaluationId: 'eval-002', + artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98', + evaluatedAt: '2025-11-27T09:30:00Z', + overallStatus: 'failed', + gates: [ + determinismFailingGate, + vulnerabilityPassingGate, + licensePassingGate, + signatureFailingGate, + ], + blockingGates: ['gate-det-002', 'gate-sig-002'], + canPublish: false, + determinismDetails: { + merkleRoot: 'sha256:f1e2d3c4b5a67890', + merkleRootConsistent: false, + contentHash: 'sha256:content9876543210', + compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json', + fragmentCount: 8, + verifiedFragments: 6, + failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'], + }, + }, +}; + +// Release fixtures +const passingRelease: Release = { + releaseId: 'rel-001', + name: 'Platform v1.2.3', + version: '1.2.3', + status: 'pending_approval', + createdAt: '2025-11-27T08:30:00Z', + createdBy: 'deploy-bot', + artifacts: [passingArtifact], + targetEnvironment: 'production', + notes: 'Feature release with API improvements and bug fixes.', + approvals: [ + { + approvalId: 'apr-001', + approver: 'security-team', + decision: 'approved', + comment: 'Security review passed.', + decidedAt: '2025-11-27T09:00:00Z', + }, + { + approvalId: 'apr-002', + approver: 'release-manager', + decision: 'pending', + }, + ], +}; + +const blockedRelease: Release = { + releaseId: 'rel-002', + name: 'Platform v1.2.4-rc1', + version: '1.2.4-rc1', + status: 'blocked', + createdAt: '2025-11-27T07:00:00Z', + createdBy: 'deploy-bot', + artifacts: [failingArtifact], + targetEnvironment: 'staging', + notes: 'Release candidate blocked due to policy gate failures.', +}; + +const mixedRelease: Release = { + releaseId: 'rel-003', + name: 'Platform v1.2.5', + version: '1.2.5', + status: 'blocked', + createdAt: '2025-11-27T06:00:00Z', + createdBy: 'ci-pipeline', + artifacts: [passingArtifact, failingArtifact], + targetEnvironment: 'production', + notes: 'Multi-artifact release with mixed policy results.', +}; + +const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease]; + +const mockFeatureFlags: DeterminismFeatureFlags = { + enabled: true, + blockOnFailure: true, + warnOnly: false, + bypassRoles: ['security-admin', 'release-manager'], + requireApprovalForBypass: true, +}; + +// ============================================================================ +// Mock API Implementation +// ============================================================================ + +@Injectable({ providedIn: 'root' }) +export class MockReleaseApi implements ReleaseApi { + getRelease(releaseId: string): Observable { + const release = mockReleases.find((r) => r.releaseId === releaseId); + if (!release) { + throw new Error(`Release not found: ${releaseId}`); + } + return of(release).pipe(delay(200)); + } + + listReleases(): Observable { + return of(mockReleases).pipe(delay(300)); + } + + publishRelease(releaseId: string): Observable { + const release = mockReleases.find((r) => r.releaseId === releaseId); + if (!release) { + throw new Error(`Release not found: ${releaseId}`); + } + // Simulate publish (would update status in real implementation) + return of({ + ...release, + status: 'published', + publishedAt: new Date().toISOString(), + } as Release).pipe(delay(500)); + } + + cancelRelease(releaseId: string): Observable { + const release = mockReleases.find((r) => r.releaseId === releaseId); + if (!release) { + throw new Error(`Release not found: ${releaseId}`); + } + return of({ + ...release, + status: 'cancelled', + } as Release).pipe(delay(300)); + } + + getFeatureFlags(): Observable { + return of(mockFeatureFlags).pipe(delay(100)); + } + + requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> { + return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400)); + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/release.models.ts b/src/Web/StellaOps.Web/src/app/core/api/release.models.ts new file mode 100644 index 000000000..98d697d8c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/release.models.ts @@ -0,0 +1,161 @@ +/** + * Release and Policy Gate models for UI-POLICY-DET-01. + * Supports determinism-gated release flows with remediation hints. + */ + +// Policy gate evaluation status +export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning'; + +// Types of policy gates +export type PolicyGateType = + | 'determinism' + | 'vulnerability' + | 'license' + | 'entropy' + | 'signature' + | 'sbom-completeness' + | 'custom'; + +// Remediation action types +export type RemediationActionType = + | 'rebuild' + | 'provide-provenance' + | 'sign-artifact' + | 'update-dependency' + | 'request-exception' + | 'manual-review'; + +/** + * A single remediation step with optional automation support. + */ +export interface RemediationStep { + readonly action: RemediationActionType; + readonly title: string; + readonly description: string; + readonly command?: string; // Optional CLI command to run + readonly documentationUrl?: string; + readonly automated: boolean; // Can be triggered from UI +} + +/** + * Remediation hints for a failed policy gate. + */ +export interface RemediationHint { + readonly gateType: PolicyGateType; + readonly severity: 'critical' | 'high' | 'medium' | 'low'; + readonly summary: string; + readonly steps: readonly RemediationStep[]; + readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour" + readonly exceptionAllowed: boolean; +} + +/** + * Individual policy gate evaluation result. + */ +export interface PolicyGateResult { + readonly gateId: string; + readonly gateType: PolicyGateType; + readonly name: string; + readonly status: PolicyGateStatus; + readonly message: string; + readonly evaluatedAt: string; + readonly blockingPublish: boolean; + readonly evidence?: { + readonly type: string; + readonly url?: string; + readonly details?: Record; + }; + readonly remediation?: RemediationHint; +} + +/** + * Determinism-specific gate details. + */ +export interface DeterminismGateDetails { + readonly merkleRoot?: string; + readonly merkleRootConsistent: boolean; + readonly contentHash?: string; + readonly compositionManifestUri?: string; + readonly fragmentCount?: number; + readonly verifiedFragments?: number; + readonly failedFragments?: readonly string[]; // Layer digests that failed +} + +/** + * Overall policy evaluation for a release artifact. + */ +export interface PolicyEvaluation { + readonly evaluationId: string; + readonly artifactDigest: string; + readonly evaluatedAt: string; + readonly overallStatus: PolicyGateStatus; + readonly gates: readonly PolicyGateResult[]; + readonly blockingGates: readonly string[]; // Gate IDs that block publish + readonly canPublish: boolean; + readonly determinismDetails?: DeterminismGateDetails; +} + +/** + * Release artifact with policy evaluation. + */ +export interface ReleaseArtifact { + readonly artifactId: string; + readonly name: string; + readonly tag: string; + readonly digest: string; + readonly size: number; + readonly createdAt: string; + readonly registry: string; + readonly policyEvaluation?: PolicyEvaluation; +} + +/** + * Release workflow status. + */ +export type ReleaseStatus = + | 'draft' + | 'pending_approval' + | 'approved' + | 'publishing' + | 'published' + | 'blocked' + | 'cancelled'; + +/** + * Release with multiple artifacts and policy gates. + */ +export interface Release { + readonly releaseId: string; + readonly name: string; + readonly version: string; + readonly status: ReleaseStatus; + readonly createdAt: string; + readonly createdBy: string; + readonly artifacts: readonly ReleaseArtifact[]; + readonly targetEnvironment: string; + readonly notes?: string; + readonly approvals?: readonly ReleaseApproval[]; + readonly publishedAt?: string; +} + +/** + * Release approval record. + */ +export interface ReleaseApproval { + readonly approvalId: string; + readonly approver: string; + readonly decision: 'approved' | 'rejected' | 'pending'; + readonly comment?: string; + readonly decidedAt?: string; +} + +/** + * Feature flag configuration for determinism blocking. + */ +export interface DeterminismFeatureFlags { + readonly enabled: boolean; + readonly blockOnFailure: boolean; + readonly warnOnly: boolean; + readonly bypassRoles?: readonly string[]; + readonly requireApprovalForBypass: boolean; +} diff --git a/src/Web/StellaOps.Web/src/app/core/api/scanner.models.ts b/src/Web/StellaOps.Web/src/app/core/api/scanner.models.ts index ae93ee3a0..6cbf7539c 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/scanner.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/scanner.models.ts @@ -9,9 +9,94 @@ export interface ScanAttestationStatus { readonly statusMessage?: string; } +// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md + +export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown'; + +export interface FragmentAttestation { + readonly layerDigest: string; + readonly fragmentSha256: string; + readonly dsseEnvelopeSha256: string; + readonly dsseStatus: 'verified' | 'pending' | 'failed'; + readonly verifiedAt?: string; +} + +export interface CompositionManifest { + readonly compositionUri: string; + readonly merkleRoot: string; + readonly fragmentCount: number; + readonly fragments: readonly FragmentAttestation[]; + readonly createdAt: string; +} + +export interface DeterminismEvidence { + readonly status: DeterminismStatus; + readonly merkleRoot?: string; + readonly merkleRootConsistent: boolean; + readonly compositionManifest?: CompositionManifest; + readonly contentHash?: string; + readonly verifiedAt?: string; + readonly failureReason?: string; + readonly stellaProperties?: { + readonly 'stellaops:stella.contentHash'?: string; + readonly 'stellaops:composition.manifest'?: string; + readonly 'stellaops:merkle.root'?: string; + }; +} + +// Entropy analysis models based on docs/modules/scanner/entropy.md + +export interface EntropyWindow { + readonly offset: number; + readonly length: number; + readonly entropy: number; // 0-8 bits/byte +} + +export interface EntropyFile { + readonly path: string; + readonly size: number; + readonly opaqueBytes: number; + readonly opaqueRatio: number; // 0-1 + readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed' + readonly windows: readonly EntropyWindow[]; +} + +export interface EntropyLayerSummary { + readonly digest: string; + readonly opaqueBytes: number; + readonly totalBytes: number; + readonly opaqueRatio: number; // 0-1 + readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols' +} + +export interface EntropyReport { + readonly schema: string; + readonly generatedAt: string; + readonly imageDigest: string; + readonly layerDigest?: string; + readonly files: readonly EntropyFile[]; +} + +export interface EntropyLayerSummaryReport { + readonly schema: string; + readonly generatedAt: string; + readonly imageDigest: string; + readonly layers: readonly EntropyLayerSummary[]; + readonly imageOpaqueRatio: number; // 0-1 + readonly entropyPenalty: number; // 0-0.3 +} + +export interface EntropyEvidence { + readonly report?: EntropyReport; + readonly layerSummary?: EntropyLayerSummaryReport; + readonly downloadUrl?: string; // URL to entropy.report.json +} + export interface ScanDetail { readonly scanId: string; readonly imageDigest: string; readonly completedAt: string; readonly attestation?: ScanAttestationStatus; + readonly determinism?: DeterminismEvidence; + readonly entropy?: EntropyEvidence; } diff --git a/src/Web/StellaOps.Web/src/app/core/auth/auth.service.ts b/src/Web/StellaOps.Web/src/app/core/auth/auth.service.ts new file mode 100644 index 000000000..887279b5d --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/auth/auth.service.ts @@ -0,0 +1,125 @@ +import { Injectable, InjectionToken, signal, computed } from '@angular/core'; +import { + StellaOpsScopes, + StellaOpsScope, + ScopeGroups, + hasScope, + hasAllScopes, + hasAnyScope, +} from './scopes'; + +/** + * User info from authentication. + */ +export interface AuthUser { + readonly id: string; + readonly email: string; + readonly name: string; + readonly tenantId: string; + readonly tenantName: string; + readonly roles: readonly string[]; + readonly scopes: readonly StellaOpsScope[]; +} + +/** + * Injection token for Auth service. + */ +export const AUTH_SERVICE = new InjectionToken('AUTH_SERVICE'); + +/** + * Auth service interface. + */ +export interface AuthService { + readonly isAuthenticated: ReturnType>; + readonly user: ReturnType>; + readonly scopes: ReturnType>; + + hasScope(scope: StellaOpsScope): boolean; + hasAllScopes(scopes: readonly StellaOpsScope[]): boolean; + hasAnyScope(scopes: readonly StellaOpsScope[]): boolean; + canViewGraph(): boolean; + canEditGraph(): boolean; + canExportGraph(): boolean; + canSimulate(): boolean; +} + +// ============================================================================ +// Mock Auth Service +// ============================================================================ + +const MOCK_USER: AuthUser = { + id: 'user-001', + email: 'developer@example.com', + name: 'Developer User', + tenantId: 'tenant-001', + tenantName: 'Acme Corp', + roles: ['developer', 'security-analyst'], + scopes: [ + // Graph permissions + StellaOpsScopes.GRAPH_READ, + StellaOpsScopes.GRAPH_WRITE, + StellaOpsScopes.GRAPH_SIMULATE, + StellaOpsScopes.GRAPH_EXPORT, + // SBOM permissions + StellaOpsScopes.SBOM_READ, + // Policy permissions + StellaOpsScopes.POLICY_READ, + StellaOpsScopes.POLICY_EVALUATE, + StellaOpsScopes.POLICY_SIMULATE, + // Scanner permissions + StellaOpsScopes.SCANNER_READ, + // Exception permissions + StellaOpsScopes.EXCEPTION_READ, + StellaOpsScopes.EXCEPTION_WRITE, + // Release permissions + StellaOpsScopes.RELEASE_READ, + // AOC permissions + StellaOpsScopes.AOC_READ, + ], +}; + +@Injectable({ providedIn: 'root' }) +export class MockAuthService implements AuthService { + readonly isAuthenticated = signal(true); + readonly user = signal(MOCK_USER); + + readonly scopes = computed(() => { + const u = this.user(); + return u?.scopes ?? []; + }); + + hasScope(scope: StellaOpsScope): boolean { + return hasScope(this.scopes(), scope); + } + + hasAllScopes(scopes: readonly StellaOpsScope[]): boolean { + return hasAllScopes(this.scopes(), scopes); + } + + hasAnyScope(scopes: readonly StellaOpsScope[]): boolean { + return hasAnyScope(this.scopes(), scopes); + } + + canViewGraph(): boolean { + return this.hasScope(StellaOpsScopes.GRAPH_READ); + } + + canEditGraph(): boolean { + return this.hasScope(StellaOpsScopes.GRAPH_WRITE); + } + + canExportGraph(): boolean { + return this.hasScope(StellaOpsScopes.GRAPH_EXPORT); + } + + canSimulate(): boolean { + return this.hasAnyScope([ + StellaOpsScopes.GRAPH_SIMULATE, + StellaOpsScopes.POLICY_SIMULATE, + ]); + } +} + +// Re-export scopes for convenience +export { StellaOpsScopes, ScopeGroups } from './scopes'; +export type { StellaOpsScope } from './scopes'; diff --git a/src/Web/StellaOps.Web/src/app/core/auth/index.ts b/src/Web/StellaOps.Web/src/app/core/auth/index.ts new file mode 100644 index 000000000..1e84bbb40 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/auth/index.ts @@ -0,0 +1,16 @@ +export { + StellaOpsScopes, + StellaOpsScope, + ScopeGroups, + ScopeLabels, + hasScope, + hasAllScopes, + hasAnyScope, +} from './scopes'; + +export { + AuthUser, + AuthService, + AUTH_SERVICE, + MockAuthService, +} from './auth.service'; diff --git a/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts b/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts new file mode 100644 index 000000000..cb6815686 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts @@ -0,0 +1,166 @@ +/** + * StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001. + * + * This is a stub implementation to unblock Graph Explorer development. + * Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers. + * + * @see docs/modules/platform/architecture-overview.md + */ + +/** + * All available StellaOps OAuth2 scopes. + */ +export const StellaOpsScopes = { + // Graph scopes + GRAPH_READ: 'graph:read', + GRAPH_WRITE: 'graph:write', + GRAPH_ADMIN: 'graph:admin', + GRAPH_EXPORT: 'graph:export', + GRAPH_SIMULATE: 'graph:simulate', + + // SBOM scopes + SBOM_READ: 'sbom:read', + SBOM_WRITE: 'sbom:write', + SBOM_ATTEST: 'sbom:attest', + + // Scanner scopes + SCANNER_READ: 'scanner:read', + SCANNER_WRITE: 'scanner:write', + SCANNER_SCAN: 'scanner:scan', + + // Policy scopes + POLICY_READ: 'policy:read', + POLICY_WRITE: 'policy:write', + POLICY_EVALUATE: 'policy:evaluate', + POLICY_SIMULATE: 'policy:simulate', + + // Exception scopes + EXCEPTION_READ: 'exception:read', + EXCEPTION_WRITE: 'exception:write', + EXCEPTION_APPROVE: 'exception:approve', + + // Release scopes + RELEASE_READ: 'release:read', + RELEASE_WRITE: 'release:write', + RELEASE_PUBLISH: 'release:publish', + RELEASE_BYPASS: 'release:bypass', + + // AOC scopes + AOC_READ: 'aoc:read', + AOC_VERIFY: 'aoc:verify', + + // Admin scopes + ADMIN: 'admin', + TENANT_ADMIN: 'tenant:admin', +} as const; + +export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes]; + +/** + * Scope groupings for common use cases. + */ +export const ScopeGroups = { + GRAPH_VIEWER: [ + StellaOpsScopes.GRAPH_READ, + StellaOpsScopes.SBOM_READ, + StellaOpsScopes.POLICY_READ, + ] as const, + + GRAPH_EDITOR: [ + StellaOpsScopes.GRAPH_READ, + StellaOpsScopes.GRAPH_WRITE, + StellaOpsScopes.SBOM_READ, + StellaOpsScopes.SBOM_WRITE, + StellaOpsScopes.POLICY_READ, + StellaOpsScopes.POLICY_EVALUATE, + ] as const, + + GRAPH_ADMIN: [ + StellaOpsScopes.GRAPH_READ, + StellaOpsScopes.GRAPH_WRITE, + StellaOpsScopes.GRAPH_ADMIN, + StellaOpsScopes.GRAPH_EXPORT, + StellaOpsScopes.GRAPH_SIMULATE, + ] as const, + + RELEASE_MANAGER: [ + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.RELEASE_WRITE, + StellaOpsScopes.RELEASE_PUBLISH, + StellaOpsScopes.POLICY_READ, + StellaOpsScopes.POLICY_EVALUATE, + ] as const, + + SECURITY_ADMIN: [ + StellaOpsScopes.EXCEPTION_READ, + StellaOpsScopes.EXCEPTION_WRITE, + StellaOpsScopes.EXCEPTION_APPROVE, + StellaOpsScopes.RELEASE_BYPASS, + StellaOpsScopes.POLICY_READ, + StellaOpsScopes.POLICY_WRITE, + ] as const, +} as const; + +/** + * Human-readable labels for scopes. + */ +export const ScopeLabels: Record = { + 'graph:read': 'View Graph', + 'graph:write': 'Edit Graph', + 'graph:admin': 'Administer Graph', + 'graph:export': 'Export Graph Data', + 'graph:simulate': 'Run Graph Simulations', + 'sbom:read': 'View SBOMs', + 'sbom:write': 'Create/Edit SBOMs', + 'sbom:attest': 'Attest SBOMs', + 'scanner:read': 'View Scan Results', + 'scanner:write': 'Configure Scanner', + 'scanner:scan': 'Trigger Scans', + 'policy:read': 'View Policies', + 'policy:write': 'Edit Policies', + 'policy:evaluate': 'Evaluate Policies', + 'policy:simulate': 'Simulate Policy Changes', + 'exception:read': 'View Exceptions', + 'exception:write': 'Create Exceptions', + 'exception:approve': 'Approve Exceptions', + 'release:read': 'View Releases', + 'release:write': 'Create Releases', + 'release:publish': 'Publish Releases', + 'release:bypass': 'Bypass Release Gates', + 'aoc:read': 'View AOC Status', + 'aoc:verify': 'Trigger AOC Verification', + 'admin': 'System Administrator', + 'tenant:admin': 'Tenant Administrator', +}; + +/** + * Check if a set of scopes includes a required scope. + */ +export function hasScope( + userScopes: readonly string[], + requiredScope: StellaOpsScope +): boolean { + return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN); +} + +/** + * Check if a set of scopes includes all required scopes. + */ +export function hasAllScopes( + userScopes: readonly string[], + requiredScopes: readonly StellaOpsScope[] +): boolean { + if (userScopes.includes(StellaOpsScopes.ADMIN)) return true; + return requiredScopes.every((scope) => userScopes.includes(scope)); +} + +/** + * Check if a set of scopes includes any of the required scopes. + */ +export function hasAnyScope( + userScopes: readonly string[], + requiredScopes: readonly StellaOpsScope[] +): boolean { + if (userScopes.includes(StellaOpsScopes.ADMIN)) return true; + return requiredScopes.some((scope) => userScopes.includes(scope)); +} diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts new file mode 100644 index 000000000..0d25188d4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts @@ -0,0 +1,200 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { EvidenceData } from '../../core/api/evidence.models'; +import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client'; +import { EvidencePanelComponent } from './evidence-panel.component'; + +@Component({ + selector: 'app-evidence-page', + standalone: true, + imports: [CommonModule, EvidencePanelComponent], + providers: [ + { provide: EVIDENCE_API, useClass: MockEvidenceApiService }, + ], + template: ` +
+ @if (loading()) { +
+
+

Loading evidence for {{ advisoryId() }}...

+
+ } @else if (error()) { + + } @else if (evidenceData()) { + + } @else { +
+

No Advisory ID

+

Please provide an advisory ID to view evidence.

+
+ } +
+ `, + styles: [` + .evidence-page { + display: flex; + flex-direction: column; + min-height: 100vh; + padding: 2rem; + background: #f3f4f6; + } + + .evidence-page__loading, + .evidence-page__error, + .evidence-page__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + text-align: center; + } + + .evidence-page__loading .spinner { + width: 2.5rem; + height: 2.5rem; + border: 3px solid #e5e7eb; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + .evidence-page__loading p { + margin-top: 1rem; + color: #6b7280; + } + + .evidence-page__error { + border: 1px solid #fca5a5; + background: #fef2f2; + } + + .evidence-page__error h2 { + color: #dc2626; + margin: 0 0 0.5rem; + } + + .evidence-page__error p { + color: #991b1b; + margin: 0 0 1rem; + } + + .evidence-page__error button { + padding: 0.5rem 1rem; + border: 1px solid #dc2626; + border-radius: 4px; + background: #fff; + color: #dc2626; + cursor: pointer; + } + + .evidence-page__error button:hover { + background: #fee2e2; + } + + .evidence-page__empty h2 { + color: #374151; + margin: 0 0 0.5rem; + } + + .evidence-page__empty p { + color: #6b7280; + margin: 0; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EvidencePageComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly evidenceApi = inject(EVIDENCE_API); + + readonly advisoryId = signal(''); + readonly evidenceData = signal(null); + readonly loading = signal(false); + readonly error = signal(null); + + constructor() { + // React to route param changes + effect(() => { + const params = this.route.snapshot.paramMap; + const id = params.get('advisoryId'); + if (id) { + this.advisoryId.set(id); + this.loadEvidence(id); + } + }, { allowSignalWrites: true }); + } + + private loadEvidence(advisoryId: string): void { + this.loading.set(true); + this.error.set(null); + + this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({ + next: (data) => { + this.evidenceData.set(data); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err.message ?? 'Failed to load evidence'); + this.loading.set(false); + }, + }); + } + + reload(): void { + const id = this.advisoryId(); + if (id) { + this.loadEvidence(id); + } + } + + onClose(): void { + this.router.navigate(['/vulnerabilities']); + } + + onDownload(event: { type: 'observation' | 'linkset'; id: string }): void { + this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({ + next: (blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${event.type}-${event.id}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + error: (err) => { + console.error('Download failed:', err); + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html new file mode 100644 index 000000000..3c38a4c6b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html @@ -0,0 +1,591 @@ + diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss new file mode 100644 index 000000000..ca2a36222 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss @@ -0,0 +1,1012 @@ +// Evidence Panel Styles +// Based on BEM naming convention + +$color-pass: #22c55e; +$color-warn: #f59e0b; +$color-block: #ef4444; +$color-pending: #6b7280; + +$color-critical: #dc2626; +$color-high: #f97316; +$color-medium: #eab308; +$color-low: #22c55e; + +$color-border: #e5e7eb; +$color-bg-muted: #f9fafb; +$color-text-muted: #6b7280; + +.evidence-panel { + display: flex; + flex-direction: column; + height: 100%; + max-height: 90vh; + background: #fff; + border-radius: 8px; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + + &__header { + padding: 1rem 1.5rem; + border-bottom: 1px solid $color-border; + background: $color-bg-muted; + } + + &__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + &__title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #111827; + } + + &__close { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + font-size: 1.5rem; + color: $color-text-muted; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + + &:hover { + background: #e5e7eb; + color: #111827; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + } + + &__decision-summary { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.75rem; + padding: 0.75rem; + border-radius: 6px; + background: #f3f4f6; + + &.decision-pass { + background: #dcfce7; + border: 1px solid #86efac; + } + + &.decision-warn { + background: #fef3c7; + border: 1px solid #fcd34d; + } + + &.decision-block { + background: #fee2e2; + border: 1px solid #fca5a5; + } + + &.decision-pending { + background: #f3f4f6; + border: 1px solid #d1d5db; + } + + .decision-badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + } + + .decision-policy { + font-weight: 500; + color: #374151; + } + + .decision-reason { + font-size: 0.875rem; + color: $color-text-muted; + } + } + + &__conflict-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding: 0.75rem; + border-radius: 6px; + background: #fef3c7; + border: 1px solid #fcd34d; + + .conflict-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: #f59e0b; + color: #fff; + font-weight: 700; + font-size: 0.875rem; + } + + .conflict-text { + flex: 1; + font-size: 0.875rem; + color: #92400e; + } + + .conflict-toggle { + padding: 0.25rem 0.5rem; + border: 1px solid #d97706; + border-radius: 4px; + background: transparent; + font-size: 0.75rem; + color: #92400e; + cursor: pointer; + + &:hover { + background: #fcd34d; + } + + &:focus { + outline: 2px solid #f59e0b; + outline-offset: 2px; + } + } + } + + &__conflict-details { + margin-top: 0.5rem; + padding: 0.75rem; + border-radius: 6px; + background: #fffbeb; + + .conflict-item { + padding: 0.5rem 0; + border-bottom: 1px solid #fcd34d; + + &:last-child { + border-bottom: none; + } + } + + .conflict-field { + display: block; + font-weight: 600; + color: #92400e; + font-size: 0.875rem; + } + + .conflict-reason { + display: block; + font-size: 0.8125rem; + color: #78350f; + margin-top: 0.25rem; + } + + .conflict-values { + margin: 0.5rem 0 0; + padding-left: 1.25rem; + font-size: 0.8125rem; + color: #92400e; + + li { + margin: 0.25rem 0; + } + } + } + + &__tabs { + display: flex; + gap: 0; + padding: 0 1rem; + border-bottom: 1px solid $color-border; + background: #fff; + } + + &__tab { + padding: 0.75rem 1rem; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + font-size: 0.875rem; + font-weight: 500; + color: $color-text-muted; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + + &:hover:not(:disabled) { + color: #374151; + } + + &.active { + color: #3b82f6; + border-bottom-color: #3b82f6; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: -2px; + } + } + + &__content { + flex: 1; + overflow-y: auto; + padding: 1rem 1.5rem; + } + + &__section { + animation: fadeIn 0.2s ease-out; + } + + &__view-toggle { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + + .view-btn { + padding: 0.5rem 0.75rem; + border: 1px solid $color-border; + border-radius: 4px; + background: #fff; + font-size: 0.8125rem; + color: $color-text-muted; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s; + + &:hover { + border-color: #9ca3af; + } + + &.active { + background: #3b82f6; + border-color: #3b82f6; + color: #fff; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + } + } +} + +// Observations Grid +.observations-grid { + display: grid; + gap: 1rem; + + &.side-by-side { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } + + &.stacked { + grid-template-columns: 1fr; + } +} + +// Observation Card +.observation-card { + border: 1px solid $color-border; + border-radius: 8px; + background: #fff; + overflow: hidden; + + &.expanded { + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: $color-bg-muted; + border-bottom: 1px solid $color-border; + } + + &__source { + display: flex; + align-items: center; + gap: 0.5rem; + + .source-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 4px; + background: #3b82f6; + color: #fff; + font-size: 0.75rem; + font-weight: 600; + } + + .source-name { + font-size: 0.875rem; + font-weight: 500; + color: #374151; + } + } + + &__download { + padding: 0.25rem 0.5rem; + border: 1px solid $color-border; + border-radius: 4px; + background: #fff; + font-size: 0.875rem; + cursor: pointer; + + &:hover { + background: #f3f4f6; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + } + + &__body { + padding: 1rem; + } + + &__title { + margin: 0 0 0.5rem; + font-size: 1rem; + font-weight: 600; + color: #111827; + } + + &__summary { + margin: 0 0 0.75rem; + font-size: 0.875rem; + color: $color-text-muted; + line-height: 1.5; + } + + &__severities { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + &__affected { + margin-bottom: 0.75rem; + font-size: 0.8125rem; + + strong { + display: block; + margin-bottom: 0.25rem; + color: #374151; + } + + ul { + margin: 0; + padding-left: 1.25rem; + } + + li { + margin: 0.25rem 0; + } + + .purl { + font-size: 0.75rem; + background: #f3f4f6; + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + + .ecosystem { + font-size: 0.75rem; + color: $color-text-muted; + } + } + + &__expand { + padding: 0.25rem 0.5rem; + border: none; + background: transparent; + font-size: 0.8125rem; + color: #3b82f6; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + } + + &__details { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid $color-border; + + .detail-section { + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } + + strong { + display: block; + margin-bottom: 0.25rem; + font-size: 0.8125rem; + color: #374151; + } + } + + .weakness-list { + font-size: 0.8125rem; + color: $color-text-muted; + } + + .reference-list { + margin: 0; + padding-left: 1.25rem; + font-size: 0.75rem; + + a { + color: #3b82f6; + word-break: break-all; + + &:hover { + text-decoration: underline; + } + } + } + + .provenance-list, + .timestamp-list { + margin: 0; + font-size: 0.8125rem; + + dt { + color: $color-text-muted; + float: left; + clear: left; + margin-right: 0.5rem; + } + + dd { + margin: 0 0 0.25rem; + color: #374151; + } + + code { + font-size: 0.75rem; + background: #f3f4f6; + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + } + } +} + +// Severity Badges +.severity-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + + &.severity-critical { + background: #fee2e2; + color: $color-critical; + } + + &.severity-high { + background: #ffedd5; + color: $color-high; + } + + &.severity-medium { + background: #fef9c3; + color: #a16207; + } + + &.severity-low { + background: #dcfce7; + color: #15803d; + } +} + +// Linkset Panel +.linkset-panel { + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #111827; + } + } + + &__download { + padding: 0.5rem 0.75rem; + border: 1px solid #3b82f6; + border-radius: 4px; + background: #fff; + font-size: 0.8125rem; + color: #3b82f6; + cursor: pointer; + + &:hover { + background: #eff6ff; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + } + + &__meta, + &__observations, + &__normalized, + &__provenance { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid $color-border; + + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } + + h4 { + margin: 0 0 0.75rem; + font-size: 0.9375rem; + font-weight: 600; + color: #374151; + } + + dl { + margin: 0; + font-size: 0.875rem; + + dt { + color: $color-text-muted; + margin-top: 0.5rem; + + &:first-child { + margin-top: 0; + } + } + + dd { + margin: 0.25rem 0 0; + color: #111827; + } + } + } + + .observation-id-list { + margin: 0; + padding-left: 1.25rem; + + li { + margin: 0.25rem 0; + } + + code { + font-size: 0.8125rem; + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 2px; + } + } + + .confidence-badge { + display: inline-block; + padding: 0.125rem 0.375rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + + &.high { + background: #dcfce7; + color: #15803d; + } + + &.medium { + background: #fef9c3; + color: #a16207; + } + + &.low { + background: #fee2e2; + color: #dc2626; + } + } +} + +// Policy Panel +.policy-panel { + &__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #111827; + } + } + + .policy-decision-badge { + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + + &.decision-pass { + background: #dcfce7; + color: #15803d; + } + + &.decision-warn { + background: #fef3c7; + color: #92400e; + } + + &.decision-block { + background: #fee2e2; + color: #dc2626; + } + + &.decision-pending { + background: #f3f4f6; + color: $color-text-muted; + } + } + + &__meta, + &__rules, + &__linksets { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid $color-border; + + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } + + h4 { + margin: 0 0 0.75rem; + font-size: 0.9375rem; + font-weight: 600; + color: #374151; + } + + dl { + margin: 0; + font-size: 0.875rem; + + dt { + color: $color-text-muted; + margin-top: 0.5rem; + + &:first-child { + margin-top: 0; + } + } + + dd { + margin: 0.25rem 0 0; + color: #111827; + + code { + font-size: 0.8125rem; + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 2px; + } + } + } + } + + .rule-list { + list-style: none; + margin: 0; + padding: 0; + } + + .rule-item { + display: flex; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 6px; + margin-bottom: 0.5rem; + + &.rule-passed { + background: #f0fdf4; + border: 1px solid #86efac; + } + + &.rule-failed { + background: #fef2f2; + border: 1px solid #fca5a5; + } + + .rule-icon { + flex-shrink: 0; + font-size: 1rem; + } + + .rule-content { + flex: 1; + min-width: 0; + } + + .rule-name { + display: block; + font-weight: 500; + color: #111827; + } + + .rule-id { + display: block; + font-size: 0.75rem; + color: $color-text-muted; + background: rgba(0, 0, 0, 0.05); + padding: 0.125rem 0.25rem; + border-radius: 2px; + margin-top: 0.25rem; + } + + .rule-reason { + margin: 0.5rem 0 0; + font-size: 0.8125rem; + color: $color-text-muted; + } + + .rule-matched { + margin-top: 0.5rem; + font-size: 0.8125rem; + + strong { + color: $color-text-muted; + } + + span { + color: #374151; + } + } + } +} + +// AOC Panel +.aoc-panel { + &__header { + margin-bottom: 1.5rem; + + h3 { + margin: 0 0 0.5rem; + font-size: 1.125rem; + font-weight: 600; + color: #111827; + } + } + + &__description { + margin: 0; + font-size: 0.875rem; + color: $color-text-muted; + } +} + +.aoc-chain { + display: flex; + flex-direction: column; + gap: 0; +} + +.aoc-entry { + display: flex; + gap: 1rem; + position: relative; + + &__connector { + display: flex; + flex-direction: column; + align-items: center; + width: 2rem; + flex-shrink: 0; + } + + &__number { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: #3b82f6; + color: #fff; + font-size: 0.875rem; + font-weight: 600; + z-index: 1; + } + + &__line { + flex: 1; + width: 2px; + background: #d1d5db; + min-height: 1rem; + } + + &__content { + flex: 1; + padding-bottom: 1rem; + border: 1px solid $color-border; + border-radius: 8px; + overflow: hidden; + margin-bottom: 0.5rem; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: $color-bg-muted; + border-bottom: 1px solid $color-border; + } + + &__toggle { + padding: 0.25rem 0.5rem; + border: 1px solid $color-border; + border-radius: 4px; + background: #fff; + font-size: 0.75rem; + cursor: pointer; + + &:hover { + background: #f3f4f6; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + } + + &__summary { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + + .aoc-hash { + font-size: 0.8125rem; + background: #f3f4f6; + padding: 0.25rem 0.5rem; + border-radius: 4px; + } + + .aoc-timestamp { + font-size: 0.8125rem; + color: $color-text-muted; + } + } + + &__details { + padding: 0 1rem 0.75rem; + border-top: 1px solid $color-border; + margin-top: 0; + padding-top: 0.75rem; + + dl { + margin: 0; + font-size: 0.8125rem; + + dt { + color: $color-text-muted; + margin-top: 0.5rem; + + &:first-child { + margin-top: 0; + } + } + + dd { + margin: 0.25rem 0 0; + color: #111827; + } + + .full-hash { + display: block; + font-size: 0.75rem; + background: #f3f4f6; + padding: 0.375rem 0.5rem; + border-radius: 4px; + word-break: break-all; + margin-top: 0.25rem; + } + } + } + + // Type-specific colors + &.aoc-type-observation .aoc-entry__number { + background: #8b5cf6; + } + + &.aoc-type-linkset .aoc-entry__number { + background: #06b6d4; + } + + &.aoc-type-policy .aoc-entry__number { + background: #f59e0b; + } + + &.aoc-type-signature .aoc-entry__number { + background: #22c55e; + } +} + +.aoc-type-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + background: #f3f4f6; + color: #374151; +} + +// Animation +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Code styling +code { + font-family: 'Monaco', 'Consolas', 'Liberation Mono', monospace; +} diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts new file mode 100644 index 000000000..293bb61e7 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts @@ -0,0 +1,255 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; + +import { + AocChainEntry, + EvidenceData, + Linkset, + LinksetConflict, + Observation, + PolicyDecision, + PolicyEvidence, + PolicyRuleResult, + SOURCE_INFO, + SourceInfo, +} from '../../core/api/evidence.models'; +import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client'; + +type TabId = 'observations' | 'linkset' | 'policy' | 'aoc'; +type ObservationView = 'side-by-side' | 'stacked'; + +@Component({ + selector: 'app-evidence-panel', + standalone: true, + imports: [CommonModule], + templateUrl: './evidence-panel.component.html', + styleUrls: ['./evidence-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EvidencePanelComponent { + private readonly evidenceApi = inject(EVIDENCE_API); + + // Inputs + readonly advisoryId = input.required(); + readonly evidenceData = input(null); + + // Outputs + readonly close = output(); + readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>(); + + // UI State + readonly activeTab = signal('observations'); + readonly observationView = signal('side-by-side'); + readonly expandedObservation = signal(null); + readonly expandedAocEntry = signal(null); + readonly showConflictDetails = signal(false); + + // Loading/error state + readonly loading = signal(false); + readonly error = signal(null); + + // Computed values + readonly observations = computed(() => this.evidenceData()?.observations ?? []); + readonly linkset = computed(() => this.evidenceData()?.linkset ?? null); + readonly policyEvidence = computed(() => this.evidenceData()?.policyEvidence ?? null); + readonly hasConflicts = computed(() => this.evidenceData()?.hasConflicts ?? false); + readonly conflictCount = computed(() => this.evidenceData()?.conflictCount ?? 0); + + readonly aocChain = computed(() => { + const policy = this.policyEvidence(); + return policy?.aocChain ?? []; + }); + + readonly policyDecisionClass = computed(() => { + const decision = this.policyEvidence()?.decision; + return this.getDecisionClass(decision); + }); + + readonly policyDecisionLabel = computed(() => { + const decision = this.policyEvidence()?.decision; + return this.getDecisionLabel(decision); + }); + + readonly observationSources = computed(() => { + const obs = this.observations(); + return obs.map((o) => this.getSourceInfo(o.source)); + }); + + // Tab methods + setActiveTab(tab: TabId): void { + this.activeTab.set(tab); + } + + isActiveTab(tab: TabId): boolean { + return this.activeTab() === tab; + } + + // Observation view methods + setObservationView(view: ObservationView): void { + this.observationView.set(view); + } + + toggleObservationExpanded(observationId: string): void { + const current = this.expandedObservation(); + this.expandedObservation.set(current === observationId ? null : observationId); + } + + isObservationExpanded(observationId: string): boolean { + return this.expandedObservation() === observationId; + } + + // AOC chain methods + toggleAocEntry(attestationId: string): void { + const current = this.expandedAocEntry(); + this.expandedAocEntry.set(current === attestationId ? null : attestationId); + } + + isAocEntryExpanded(attestationId: string): boolean { + return this.expandedAocEntry() === attestationId; + } + + // Conflict methods + toggleConflictDetails(): void { + this.showConflictDetails.update((v) => !v); + } + + // Source info helper + getSourceInfo(sourceId: string): SourceInfo { + return ( + SOURCE_INFO[sourceId] ?? { + sourceId, + name: sourceId.toUpperCase(), + icon: 'file', + } + ); + } + + // Decision helpers + getDecisionClass(decision: PolicyDecision | undefined): string { + switch (decision) { + case 'pass': + return 'decision-pass'; + case 'warn': + return 'decision-warn'; + case 'block': + return 'decision-block'; + case 'pending': + default: + return 'decision-pending'; + } + } + + getDecisionLabel(decision: PolicyDecision | undefined): string { + switch (decision) { + case 'pass': + return 'Passed'; + case 'warn': + return 'Warning'; + case 'block': + return 'Blocked'; + case 'pending': + default: + return 'Pending'; + } + } + + // Rule result helpers + getRuleClass(passed: boolean): string { + return passed ? 'rule-passed' : 'rule-failed'; + } + + getRuleIcon(passed: boolean): string { + return passed ? 'check-circle' : 'x-circle'; + } + + // AOC chain helpers + getAocTypeLabel(type: AocChainEntry['type']): string { + switch (type) { + case 'observation': + return 'Observation'; + case 'linkset': + return 'Linkset'; + case 'policy': + return 'Policy Decision'; + case 'signature': + return 'Signature'; + default: + return type; + } + } + + getAocTypeClass(type: AocChainEntry['type']): string { + return `aoc-type-${type}`; + } + + // Severity helpers + getSeverityClass(score: number): string { + if (score >= 9.0) return 'severity-critical'; + if (score >= 7.0) return 'severity-high'; + if (score >= 4.0) return 'severity-medium'; + return 'severity-low'; + } + + getSeverityLabel(score: number): string { + if (score >= 9.0) return 'Critical'; + if (score >= 7.0) return 'High'; + if (score >= 4.0) return 'Medium'; + return 'Low'; + } + + // Download handlers + onDownloadObservation(observationId: string): void { + this.downloadDocument.emit({ type: 'observation', id: observationId }); + } + + onDownloadLinkset(linksetId: string): void { + this.downloadDocument.emit({ type: 'linkset', id: linksetId }); + } + + // Close handler + onClose(): void { + this.close.emit(); + } + + // Date formatting + formatDate(dateStr: string | undefined): string { + if (!dateStr) return 'N/A'; + try { + return new Date(dateStr).toLocaleString(); + } catch { + return dateStr; + } + } + + // Hash truncation for display + truncateHash(hash: string | undefined, length = 12): string { + if (!hash) return 'N/A'; + if (hash.length <= length) return hash; + return hash.slice(0, length) + '...'; + } + + // Track by functions for ngFor + trackByObservationId(_: number, obs: Observation): string { + return obs.observationId; + } + + trackByAocId(_: number, entry: AocChainEntry): string { + return entry.attestationId; + } + + trackByConflictField(_: number, conflict: LinksetConflict): string { + return conflict.field; + } + + trackByRuleId(_: number, rule: PolicyRuleResult): string { + return rule.ruleId; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/index.ts b/src/Web/StellaOps.Web/src/app/features/evidence/index.ts new file mode 100644 index 000000000..930d5532b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence/index.ts @@ -0,0 +1,2 @@ +export { EvidencePanelComponent } from './evidence-panel.component'; +export { EvidencePageComponent } from './evidence-page.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts b/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts index 9c97eed7f..442c4f55e 100644 --- a/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/graph/graph-explorer.component.ts @@ -4,6 +4,7 @@ import { Component, OnInit, computed, + inject, signal, } from '@angular/core'; @@ -17,6 +18,12 @@ import { ExceptionExplainComponent, ExceptionExplainData, } from '../../shared/components'; +import { + AUTH_SERVICE, + AuthService, + MockAuthService, + StellaOpsScopes, +} from '../../core/auth'; export interface GraphNode { readonly id: string; @@ -74,11 +81,27 @@ type ViewMode = 'hierarchy' | 'flat'; selector: 'app-graph-explorer', standalone: true, imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent], + providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }], templateUrl: './graph-explorer.component.html', styleUrls: ['./graph-explorer.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class GraphExplorerComponent implements OnInit { + private readonly authService = inject(AUTH_SERVICE); + + // Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001) + readonly canViewGraph = computed(() => this.authService.canViewGraph()); + readonly canEditGraph = computed(() => this.authService.canEditGraph()); + readonly canExportGraph = computed(() => this.authService.canExportGraph()); + readonly canSimulate = computed(() => this.authService.canSimulate()); + readonly canCreateException = computed(() => + this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE) + ); + + // Current user info + readonly currentUser = computed(() => this.authService.user()); + readonly userScopes = computed(() => this.authService.scopes()); + // View state readonly loading = signal(false); readonly message = signal(null); diff --git a/src/Web/StellaOps.Web/src/app/features/releases/index.ts b/src/Web/StellaOps.Web/src/app/features/releases/index.ts new file mode 100644 index 000000000..377006db7 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/index.ts @@ -0,0 +1,3 @@ +export { ReleaseFlowComponent } from './release-flow.component'; +export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component'; +export { RemediationHintsComponent } from './remediation-hints.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/releases/policy-gate-indicator.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/policy-gate-indicator.component.ts new file mode 100644 index 000000000..b47c9db74 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/policy-gate-indicator.component.ts @@ -0,0 +1,328 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal, +} from '@angular/core'; +import { + PolicyGateResult, + PolicyGateStatus, + DeterminismFeatureFlags, +} from '../../core/api/release.models'; + +@Component({ + selector: 'app-policy-gate-indicator', + standalone: true, + imports: [CommonModule], + template: ` +
+ + + @if (expanded()) { +
+

{{ gate().message }}

+
+ + Evaluated: {{ formatDate(gate().evaluatedAt) }} + + @if (gate().evidence?.url) { + + View Evidence + + } +
+ + + @if (gate().gateType === 'determinism' && featureFlags()?.enabled) { +
+ @if (featureFlags()?.blockOnFailure) { + Determinism Blocking Enabled + } @else if (featureFlags()?.warnOnly) { + Determinism Warn-Only Mode + } +
+ } +
+ } +
+ `, + styles: [` + .gate-indicator { + background: #1e293b; + border: 1px solid #334155; + border-radius: 6px; + overflow: hidden; + transition: border-color 0.15s; + + &--passed { + border-left: 3px solid #22c55e; + } + + &--failed { + border-left: 3px solid #ef4444; + } + + &--warning { + border-left: 3px solid #f97316; + } + + &--pending { + border-left: 3px solid #eab308; + } + + &--skipped { + border-left: 3px solid #64748b; + } + + &--expanded { + border-color: #475569; + } + } + + .gate-header { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + color: #e2e8f0; + cursor: pointer; + text-align: left; + + &:hover { + background: rgba(255, 255, 255, 0.03); + } + } + + .gate-status { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 80px; + } + + .status-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 0.75rem; + font-weight: bold; + } + + .gate-indicator--passed .status-icon { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + } + + .gate-indicator--failed .status-icon { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + } + + .gate-indicator--warning .status-icon { + background: rgba(249, 115, 22, 0.2); + color: #f97316; + } + + .gate-indicator--pending .status-icon { + background: rgba(234, 179, 8, 0.2); + color: #eab308; + } + + .gate-indicator--skipped .status-icon { + background: rgba(100, 116, 139, 0.2); + color: #64748b; + } + + .status-text { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .gate-indicator--passed .status-text { color: #22c55e; } + .gate-indicator--failed .status-text { color: #ef4444; } + .gate-indicator--warning .status-text { color: #f97316; } + .gate-indicator--pending .status-text { color: #eab308; } + .gate-indicator--skipped .status-text { color: #64748b; } + + .gate-info { + flex: 1; + display: flex; + align-items: center; + gap: 0.75rem; + } + + .gate-name { + font-weight: 500; + } + + .gate-type-badge { + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + + &--determinism { + background: rgba(147, 51, 234, 0.2); + color: #a855f7; + } + } + + .blocking-badge { + padding: 0.125rem 0.5rem; + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + border-radius: 4px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + } + + .expand-icon { + color: #64748b; + font-size: 0.625rem; + } + + .gate-details { + padding: 0 1rem 1rem 1rem; + border-top: 1px solid #334155; + margin-top: 0; + } + + .gate-message { + margin: 0.75rem 0; + color: #94a3b8; + font-size: 0.875rem; + line-height: 1.5; + } + + .gate-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + font-size: 0.8125rem; + color: #64748b; + + strong { + color: #94a3b8; + } + } + + .evidence-link { + color: #3b82f6; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .feature-flag-info { + margin-top: 0.75rem; + } + + .flag-badge { + display: inline-block; + padding: 0.25rem 0.625rem; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 500; + + &--active { + background: rgba(147, 51, 234, 0.2); + color: #a855f7; + } + + &--warn { + background: rgba(234, 179, 8, 0.2); + color: #eab308; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyGateIndicatorComponent { + readonly gate = input.required(); + readonly featureFlags = input(null); + + readonly expanded = signal(false); + + readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism'); + + toggleExpanded(): void { + this.expanded.update((v) => !v); + } + + getStatusLabel(): string { + const labels: Record = { + passed: 'Passed', + failed: 'Failed', + pending: 'Pending', + warning: 'Warning', + skipped: 'Skipped', + }; + return labels[this.gate().status] ?? 'Unknown'; + } + + getStatusIconClass(): string { + return `status-icon--${this.gate().status}`; + } + + formatDate(isoString: string): string { + try { + return new Date(isoString).toLocaleString(); + } catch { + return isoString; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html new file mode 100644 index 000000000..2393101ab --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html @@ -0,0 +1,331 @@ +
+ +
+
+ @if (viewMode() === 'detail') { + + } +

{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}

+
+
+ @if (isDeterminismEnabled()) { + + Determinism Gates Active + + } @else { + + Determinism Gates Disabled + + } +
+
+ + + @if (loading()) { +
+
+

Loading releases...

+
+ } + + + @if (!loading() && viewMode() === 'list') { +
+ @for (release of releases(); track trackByReleaseId($index, release)) { +
+
+

{{ release.name }}

+ + {{ release.status | titlecase }} + +
+
+ + Version: {{ release.version }} + + + Target: {{ release.targetEnvironment }} + + + Artifacts: {{ release.artifacts.length }} + +
+
+ @for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) { + @if (artifact.policyEvaluation) { +
+ {{ artifact.name }}: + @for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) { + + } +
+ } + } +
+ @if (release.status === 'blocked') { +
+ + Policy gates blocking publish +
+ } +
+ } @empty { +

No releases found.

+ } +
+ } + + + @if (!loading() && viewMode() === 'detail' && selectedRelease()) { +
+ +
+

Release Information

+
+
+
Version
+
{{ selectedRelease()?.version }}
+
+
+
Status
+
+ + {{ selectedRelease()?.status | titlecase }} + +
+
+
+
Target Environment
+
{{ selectedRelease()?.targetEnvironment }}
+
+
+
Created
+
{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}
+
+
+ @if (selectedRelease()?.notes) { +

{{ selectedRelease()?.notes }}

+ } +
+ + + @if (isDeterminismEnabled() && determinismBlockingCount() > 0) { +
+ + +
+ } + + +
+

Artifacts ({{ selectedRelease()?.artifacts?.length }})

+
+ @for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) { + + } +
+ + + @if (selectedArtifact()) { +
+
+
+
Digest
+
{{ selectedArtifact()?.digest }}
+
+
+
Size
+
{{ formatBytes(selectedArtifact()!.size) }}
+
+
+
Registry
+
{{ selectedArtifact()?.registry }}
+
+
+ + + @if (selectedArtifact()?.policyEvaluation) { +
+

Policy Gates

+
+ @for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) { + + } +
+ + + @if (selectedArtifact()!.policyEvaluation!.determinismDetails) { +
+

Determinism Evidence

+
+
+
Merkle Root
+
+ {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }} + @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) { + Consistent + } @else { + Mismatch + } +
+
+
+
Fragment Verification
+
+ {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} / + {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified +
+
+ @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) { +
+
Composition Manifest
+
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}
+
+ } +
+ + + @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) { +
+
Failed Fragment Layers
+
    + @for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) { +
  • {{ fragment }}
  • + } +
+
+ } +
+ } + + + @for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) { + @if (gate.status === 'failed' && gate.remediation) { + + } + } +
+ } +
+ } +
+ + +
+

Actions

+
+ @if (canPublishSelected()) { + + } @else { + + @if (canBypass() && determinismBlockingCount() > 0) { + + } + } + +
+
+
+ } + + + @if (showBypassModal()) { + + } +
diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.scss b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.scss new file mode 100644 index 000000000..c29039790 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.scss @@ -0,0 +1,661 @@ +.release-flow { + display: grid; + gap: 1.5rem; + padding: 1.5rem; + color: #e2e8f0; + background: #0f172a; + min-height: calc(100vh - 120px); +} + +// Header +.release-flow__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.header-left { + display: flex; + align-items: center; + gap: 1rem; + + h1 { + margin: 0; + font-size: 1.5rem; + } +} + +.header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.back-button { + background: transparent; + border: 1px solid #334155; + color: #94a3b8; + padding: 0.375rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + + &:hover { + background: #1e293b; + color: #e2e8f0; + } +} + +.feature-badge { + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + + &--enabled { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.3); + } + + &--disabled { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; + border: 1px solid rgba(100, 116, 139, 0.3); + } +} + +// Loading +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: #94a3b8; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #334155; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +// Releases List +.releases-list { + display: grid; + gap: 1rem; +} + +.release-card { + background: #111827; + border: 1px solid #1f2933; + border-radius: 8px; + padding: 1.25rem; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + + &:hover, + &:focus { + background: #1e293b; + border-color: #334155; + outline: none; + } + + &--blocked { + border-left: 4px solid #ef4444; + } +} + +.release-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + + h2 { + margin: 0; + font-size: 1.125rem; + } +} + +.release-status { + padding: 0.25rem 0.625rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; +} + +.release-status--draft { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; +} + +.release-status--pending { + background: rgba(234, 179, 8, 0.2); + color: #eab308; +} + +.release-status--approved { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.release-status--publishing { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.release-status--published { + background: rgba(34, 197, 94, 0.3); + color: #22c55e; +} + +.release-status--blocked { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.release-status--cancelled { + background: rgba(100, 116, 139, 0.2); + color: #64748b; +} + +.release-card__meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 0.75rem; + font-size: 0.875rem; + color: #94a3b8; + + strong { + color: #cbd5e1; + } +} + +.release-card__gates { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.artifact-gates { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + color: #94a3b8; +} + +.gate-pip { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status--passed { + background: #22c55e; +} + +.status--failed { + background: #ef4444; +} + +.status--pending { + background: #eab308; +} + +.status--warning { + background: #f97316; +} + +.status--skipped { + background: #64748b; +} + +.release-card__warning { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; + font-size: 0.875rem; + color: #ef4444; +} + +.warning-icon { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: #ef4444; + color: #111827; + border-radius: 50%; + font-weight: bold; + font-size: 0.75rem; +} + +.empty-state { + text-align: center; + color: #64748b; + padding: 2rem; +} + +// Detail View +.release-detail { + display: grid; + gap: 1.5rem; +} + +.detail-section { + background: #111827; + border: 1px solid #1f2933; + border-radius: 8px; + padding: 1.25rem; + + h2 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + color: #e2e8f0; + } + + h3 { + margin: 1rem 0 0.75rem 0; + font-size: 1rem; + color: #cbd5e1; + } + + h4 { + margin: 1rem 0 0.5rem 0; + font-size: 0.875rem; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + h5 { + margin: 0.75rem 0 0.5rem 0; + font-size: 0.8125rem; + color: #ef4444; + } +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin: 0; + + dt { + font-size: 0.75rem; + text-transform: uppercase; + color: #64748b; + margin-bottom: 0.25rem; + } + + dd { + margin: 0; + color: #e2e8f0; + } +} + +.release-notes { + margin: 1rem 0 0 0; + padding: 0.75rem 1rem; + background: #0f172a; + border-radius: 4px; + color: #94a3b8; + font-style: italic; +} + +// Determinism Blocking Banner +.determinism-blocking-banner { + display: flex; + align-items: flex-start; + gap: 1rem; + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); +} + +.banner-icon { + flex-shrink: 0; + color: #ef4444; +} + +.banner-content { + h3 { + margin: 0 0 0.5rem 0; + font-size: 1rem; + color: #ef4444; + } + + p { + margin: 0; + color: #fca5a5; + font-size: 0.875rem; + } +} + +// Artifacts Tabs +.artifacts-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.artifact-tab { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 4px; + color: #94a3b8; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: #1e293b; + color: #e2e8f0; + } + + &--active { + background: #1d4ed8; + border-color: #1d4ed8; + color: #f8fafc; + } + + &--blocked:not(.artifact-tab--active) { + border-color: rgba(239, 68, 68, 0.5); + } +} + +.artifact-tab__name { + font-weight: 500; +} + +.artifact-tab__tag { + font-size: 0.75rem; + opacity: 0.8; +} + +.artifact-tab__blocked { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + background: #ef4444; + color: #111827; + border-radius: 50%; + font-weight: bold; + font-size: 0.625rem; +} + +// Artifact Detail +.artifact-detail { + padding: 1rem; + background: #0f172a; + border-radius: 4px; +} + +.artifact-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; + margin: 0 0 1rem 0; + + dt { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + margin-bottom: 0.125rem; + } + + dd { + margin: 0; + font-size: 0.8125rem; + word-break: break-all; + } + + code { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.75rem; + color: #94a3b8; + } +} + +// Policy Gates +.policy-gates { + margin-top: 1rem; +} + +.gates-list { + display: grid; + gap: 0.75rem; +} + +// Determinism Details +.determinism-details { + margin-top: 1rem; + padding: 1rem; + background: #111827; + border-radius: 4px; + border: 1px solid #1f2933; + + dl { + margin: 0; + display: grid; + gap: 0.75rem; + } + + dt { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + margin-bottom: 0.125rem; + } + + dd { + margin: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + + code { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.75rem; + color: #94a3b8; + word-break: break-all; + } + } +} + +.consistency-badge { + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; + + &--consistent { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + } + + &--inconsistent { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + } +} + +.failed-fragments { + margin-top: 0.75rem; + padding: 0.75rem; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; + + ul { + margin: 0; + padding-left: 1.25rem; + list-style: disc; + + li { + margin: 0.25rem 0; + color: #fca5a5; + } + + code { + font-size: 0.75rem; + } + } +} + +// Actions Section +.actions-section { + background: #0f172a; +} + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.btn { + padding: 0.625rem 1.25rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + border: none; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &--primary { + background: #1d4ed8; + color: #f8fafc; + + &:hover:not(:disabled) { + background: #1e40af; + } + } + + &--secondary { + background: #334155; + color: #e2e8f0; + + &:hover:not(:disabled) { + background: #475569; + } + } + + &--warning { + background: #d97706; + color: #f8fafc; + + &:hover:not(:disabled) { + background: #b45309; + } + } + + &--disabled { + background: #475569; + color: #94a3b8; + } +} + +// Modal +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-content { + background: #111827; + border: 1px solid #1f2933; + border-radius: 8px; + padding: 1.5rem; + width: 100%; + max-width: 500px; + + h2 { + margin: 0 0 0.75rem 0; + font-size: 1.25rem; + color: #e2e8f0; + } + + label { + display: block; + margin: 1rem 0 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: #cbd5e1; + } + + textarea { + width: 100%; + padding: 0.75rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 4px; + color: #e2e8f0; + font-family: inherit; + font-size: 0.875rem; + resize: vertical; + + &:focus { + outline: none; + border-color: #3b82f6; + } + + &::placeholder { + color: #64748b; + } + } +} + +.modal-description { + margin: 0; + color: #94a3b8; + font-size: 0.875rem; +} + +.modal-actions { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; + justify-content: flex-end; +} diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts new file mode 100644 index 000000000..df26e8e47 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts @@ -0,0 +1,229 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { + Release, + ReleaseArtifact, + PolicyGateResult, + PolicyGateStatus, + DeterminismFeatureFlags, +} from '../../core/api/release.models'; +import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client'; +import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component'; +import { RemediationHintsComponent } from './remediation-hints.component'; + +type ViewMode = 'list' | 'detail'; + +@Component({ + selector: 'app-release-flow', + standalone: true, + imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent], + providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }], + templateUrl: './release-flow.component.html', + styleUrls: ['./release-flow.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReleaseFlowComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly releaseApi = inject(RELEASE_API); + + // State + readonly releases = signal([]); + readonly selectedRelease = signal(null); + readonly selectedArtifact = signal(null); + readonly featureFlags = signal(null); + readonly loading = signal(true); + readonly publishing = signal(false); + readonly viewMode = signal('list'); + readonly bypassReason = signal(''); + readonly showBypassModal = signal(false); + + // Computed values + readonly canPublishSelected = computed(() => { + const release = this.selectedRelease(); + if (!release) return false; + return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false); + }); + + readonly blockingGatesCount = computed(() => { + const release = this.selectedRelease(); + if (!release) return 0; + return release.artifacts.reduce((count, artifact) => { + return count + (artifact.policyEvaluation?.blockingGates.length ?? 0); + }, 0); + }); + + readonly determinismBlockingCount = computed(() => { + const release = this.selectedRelease(); + if (!release) return 0; + return release.artifacts.reduce((count, artifact) => { + const gates = artifact.policyEvaluation?.gates ?? []; + const deterministicBlocking = gates.filter( + (g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish + ); + return count + deterministicBlocking.length; + }, 0); + }); + + readonly isDeterminismEnabled = computed(() => { + const flags = this.featureFlags(); + return flags?.enabled ?? false; + }); + + readonly canBypass = computed(() => { + const flags = this.featureFlags(); + return flags?.bypassRoles && flags.bypassRoles.length > 0; + }); + + ngOnInit(): void { + this.loadData(); + } + + private loadData(): void { + this.loading.set(true); + + // Load feature flags + this.releaseApi.getFeatureFlags().subscribe({ + next: (flags) => this.featureFlags.set(flags), + error: (err) => console.error('Failed to load feature flags:', err), + }); + + // Load releases + this.releaseApi.listReleases().subscribe({ + next: (releases) => { + this.releases.set(releases); + this.loading.set(false); + + // Check if we should auto-select from route + const releaseId = this.route.snapshot.paramMap.get('releaseId'); + if (releaseId) { + const release = releases.find((r) => r.releaseId === releaseId); + if (release) { + this.selectRelease(release); + } + } + }, + error: (err) => { + console.error('Failed to load releases:', err); + this.loading.set(false); + }, + }); + } + + selectRelease(release: Release): void { + this.selectedRelease.set(release); + this.selectedArtifact.set(release.artifacts[0] ?? null); + this.viewMode.set('detail'); + } + + selectArtifact(artifact: ReleaseArtifact): void { + this.selectedArtifact.set(artifact); + } + + backToList(): void { + this.selectedRelease.set(null); + this.selectedArtifact.set(null); + this.viewMode.set('list'); + } + + publishRelease(): void { + const release = this.selectedRelease(); + if (!release || !this.canPublishSelected()) return; + + this.publishing.set(true); + this.releaseApi.publishRelease(release.releaseId).subscribe({ + next: (updated) => { + // Update the release in the list + this.releases.update((list) => + list.map((r) => (r.releaseId === updated.releaseId ? updated : r)) + ); + this.selectedRelease.set(updated); + this.publishing.set(false); + }, + error: (err) => { + console.error('Publish failed:', err); + this.publishing.set(false); + }, + }); + } + + openBypassModal(): void { + this.bypassReason.set(''); + this.showBypassModal.set(true); + } + + closeBypassModal(): void { + this.showBypassModal.set(false); + } + + submitBypassRequest(): void { + const release = this.selectedRelease(); + const reason = this.bypassReason(); + if (!release || !reason.trim()) return; + + this.releaseApi.requestBypass(release.releaseId, reason).subscribe({ + next: (result) => { + console.log('Bypass requested:', result.requestId); + this.closeBypassModal(); + // In real implementation, would show notification and refresh + }, + error: (err) => console.error('Bypass request failed:', err), + }); + } + + updateBypassReason(event: Event): void { + const target = event.target as HTMLTextAreaElement; + this.bypassReason.set(target.value); + } + + getStatusClass(status: PolicyGateStatus): string { + const statusClasses: Record = { + passed: 'status--passed', + failed: 'status--failed', + pending: 'status--pending', + warning: 'status--warning', + skipped: 'status--skipped', + }; + return statusClasses[status] ?? 'status--pending'; + } + + getReleaseStatusClass(release: Release): string { + const statusClasses: Record = { + draft: 'release-status--draft', + pending_approval: 'release-status--pending', + approved: 'release-status--approved', + publishing: 'release-status--publishing', + published: 'release-status--published', + blocked: 'release-status--blocked', + cancelled: 'release-status--cancelled', + }; + return statusClasses[release.status] ?? 'release-status--draft'; + } + + formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + trackByReleaseId(_index: number, release: Release): string { + return release.releaseId; + } + + trackByArtifactId(_index: number, artifact: ReleaseArtifact): string { + return artifact.artifactId; + } + + trackByGateId(_index: number, gate: PolicyGateResult): string { + return gate.gateId; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/releases/remediation-hints.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/remediation-hints.component.ts new file mode 100644 index 000000000..5fafb14c4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/releases/remediation-hints.component.ts @@ -0,0 +1,507 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { + PolicyGateResult, + RemediationHint, + RemediationStep, + RemediationActionType, +} from '../../core/api/release.models'; + +@Component({ + selector: 'app-remediation-hints', + standalone: true, + imports: [CommonModule], + template: ` +
+ + + @if (expanded()) { +
+ +

{{ hint().summary }}

+ + + @if (hint().estimatedEffort) { +
+ + Estimated effort: {{ hint().estimatedEffort }} +
+ } + + +
    + @for (step of hint().steps; track step.action; let i = $index) { +
  1. +
    + {{ i + 1 }} + {{ step.title }} + @if (step.automated) { + Automated + } + + {{ getActionTypeIcon(step.action) }} + +
    +

    {{ step.description }}

    + + @if (step.command) { +
    + {{ step.command }} + +
    + } + + @if (step.documentationUrl) { + + View documentation → + + } + + @if (step.automated) { + + } +
  2. + } +
+ + + @if (hint().exceptionAllowed) { +
+
+ + A policy exception can be requested if compensating controls are in place. +
+ +
+ } +
+ } +
+ `, + styles: [` + .remediation-hints { + background: #1e293b; + border: 1px solid #334155; + border-radius: 6px; + margin-top: 1rem; + overflow: hidden; + } + + .remediation-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.75rem 1rem; + background: rgba(239, 68, 68, 0.05); + border: none; + border-bottom: 1px solid transparent; + color: #e2e8f0; + cursor: pointer; + text-align: left; + + &:hover { + background: rgba(239, 68, 68, 0.1); + } + } + + .remediation-hints:not(.remediation-hints--collapsed) .remediation-header { + border-bottom-color: #334155; + } + + .header-content { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .header-icon { + color: #f97316; + } + + .header-title { + font-weight: 500; + } + + .severity-badge { + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + + &--critical { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + } + + &--high { + background: rgba(249, 115, 22, 0.2); + color: #f97316; + } + + &--medium { + background: rgba(234, 179, 8, 0.2); + color: #eab308; + } + + &--low { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; + } + } + + .expand-icon { + color: #64748b; + font-size: 0.625rem; + } + + .remediation-content { + padding: 1rem; + } + + .remediation-summary { + margin: 0 0 1rem 0; + color: #94a3b8; + font-size: 0.875rem; + line-height: 1.5; + } + + .effort-indicator { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding: 0.5rem 0.75rem; + background: rgba(59, 130, 246, 0.1); + border-radius: 4px; + font-size: 0.8125rem; + color: #94a3b8; + + strong { + color: #3b82f6; + } + } + + .effort-icon { + font-size: 0.875rem; + } + + .remediation-steps { + margin: 0; + padding: 0; + list-style: none; + } + + .step { + position: relative; + padding: 1rem; + margin-bottom: 0.75rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 6px; + + &:last-child { + margin-bottom: 0; + } + + &--automated { + border-color: rgba(34, 197, 94, 0.3); + } + } + + .step-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; + } + + .step-number { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: #334155; + color: #e2e8f0; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; + } + + .step-title { + flex: 1; + font-weight: 500; + color: #e2e8f0; + } + + .automated-badge { + padding: 0.125rem 0.5rem; + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + border-radius: 4px; + font-size: 0.625rem; + font-weight: 500; + text-transform: uppercase; + } + + .action-type-icon { + font-size: 1rem; + } + + .step-description { + margin: 0 0 0.75rem 0; + padding-left: calc(22px + 0.75rem); + color: #94a3b8; + font-size: 0.8125rem; + line-height: 1.5; + } + + .step-command { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.75rem 0; + margin-left: calc(22px + 0.75rem); + padding: 0.5rem 0.75rem; + background: #111827; + border: 1px solid #1f2933; + border-radius: 4px; + + code { + flex: 1; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.75rem; + color: #22c55e; + word-break: break-all; + } + } + + .copy-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + background: transparent; + border: none; + color: #64748b; + cursor: pointer; + + &:hover { + color: #e2e8f0; + } + } + + .docs-link { + display: inline-block; + margin-left: calc(22px + 0.75rem); + margin-bottom: 0.5rem; + color: #3b82f6; + font-size: 0.8125rem; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .action-button { + margin-left: calc(22px + 0.75rem); + padding: 0.5rem 1rem; + background: #22c55e; + border: none; + border-radius: 4px; + color: #0f172a; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: #16a34a; + } + } + + .exception-option { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 1rem; + padding: 1rem; + background: rgba(147, 51, 234, 0.1); + border: 1px solid rgba(147, 51, 234, 0.2); + border-radius: 6px; + } + + .exception-info { + display: flex; + align-items: center; + gap: 0.5rem; + color: #a855f7; + font-size: 0.8125rem; + } + + .exception-icon { + flex-shrink: 0; + } + + .exception-button { + padding: 0.5rem 1rem; + background: #7c3aed; + border: none; + border-radius: 4px; + color: #f8fafc; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: #6d28d9; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RemediationHintsComponent { + readonly gate = input.required(); + readonly actionTriggered = output<{ gate: PolicyGateResult; step: RemediationStep }>(); + readonly exceptionRequested = output(); + + readonly expanded = signal(true); // Default expanded for failed gates + readonly copiedCommand = signal(null); + + readonly hint = computed(() => { + return ( + this.gate().remediation ?? { + gateType: this.gate().gateType, + severity: 'medium', + summary: 'No specific remediation steps available.', + steps: [], + exceptionAllowed: false, + } + ); + }); + + toggleExpanded(): void { + this.expanded.update((v) => !v); + } + + getActionTypeIcon(action: RemediationActionType): string { + const icons: Record = { + rebuild: '🔨', + 'provide-provenance': '📜', + 'sign-artifact': '🔐', + 'update-dependency': '📦', + 'request-exception': '🛡️', + 'manual-review': '👁️', + }; + return icons[action] ?? '📋'; + } + + getActionTypeLabel(action: RemediationActionType): string { + const labels: Record = { + rebuild: 'Rebuild required', + 'provide-provenance': 'Provide provenance', + 'sign-artifact': 'Sign artifact', + 'update-dependency': 'Update dependency', + 'request-exception': 'Request exception', + 'manual-review': 'Manual review', + }; + return labels[action] ?? action; + } + + getActionButtonLabel(action: RemediationActionType): string { + const labels: Record = { + rebuild: 'Trigger Rebuild', + 'provide-provenance': 'Upload Provenance', + 'sign-artifact': 'Sign Now', + 'update-dependency': 'Update', + 'request-exception': 'Request', + 'manual-review': 'Start Review', + }; + return labels[action] ?? 'Execute'; + } + + async copyCommand(command: string): Promise { + try { + await navigator.clipboard.writeText(command); + this.copiedCommand.set(command); + setTimeout(() => this.copiedCommand.set(null), 2000); + } catch (err) { + console.error('Failed to copy command:', err); + } + } + + triggerAction(step: RemediationStep): void { + this.actionTriggered.emit({ gate: this.gate(), step }); + } + + requestException(): void { + this.exceptionRequested.emit(this.gate()); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/scans/determinism-badge.component.ts b/src/Web/StellaOps.Web/src/app/features/scans/determinism-badge.component.ts new file mode 100644 index 000000000..1a80153f4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/scans/determinism-badge.component.ts @@ -0,0 +1,608 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal, +} from '@angular/core'; + +import { + DeterminismEvidence, + DeterminismStatus, + FragmentAttestation, +} from '../../core/api/scanner.models'; + +@Component({ + selector: 'app-determinism-badge', + standalone: true, + imports: [CommonModule], + template: ` +
+ + + + + @if (expanded() && evidence()) { +
+ +
+

Merkle Root

+
+ @if (evidence()?.merkleRoot) { + {{ evidence()?.merkleRoot }} + + {{ evidence()?.merkleRootConsistent ? 'Consistent' : 'Inconsistent' }} + + } @else { + No Merkle root available + } +
+
+ + + @if (evidence()?.contentHash) { +
+

Content Hash

+ {{ evidence()?.contentHash }} +
+ } + + + @if (evidence()?.compositionManifest; as manifest) { +
+

Composition Manifest

+
+
URI:
+
+ {{ manifest.compositionUri }} +
+
Fragment Count:
+
{{ manifest.fragmentCount }}
+
Created:
+
{{ formatDate(manifest.createdAt) }}
+
+ + + @if (manifest.fragments.length > 0) { +
+
+ Fragment Attestations ({{ manifest.fragments.length }}) +
+ + + @if (showFragments()) { +
    + @for (fragment of manifest.fragments; track fragment.layerDigest) { +
  • +
    + + @switch (fragment.dsseStatus) { + @case ('verified') { ✓ } + @case ('pending') { ⌛ } + @case ('failed') { ✗ } + } + + + Layer: {{ truncateHash(fragment.layerDigest, 16) }} + +
    +
    +
    + Fragment SHA256: + {{ truncateHash(fragment.fragmentSha256, 20) }} +
    +
    + DSSE Envelope: + {{ truncateHash(fragment.dsseEnvelopeSha256, 20) }} +
    + @if (fragment.verifiedAt) { +
    + Verified: + {{ formatDate(fragment.verifiedAt) }} +
    + } +
    +
  • + } +
+ } +
+ } +
+ } + + + @if (evidence()?.stellaProperties) { +
+

Stella Properties

+
+ @if (evidence()?.stellaProperties?.['stellaops:stella.contentHash']) { +
stellaops:stella.contentHash
+
+ {{ truncateHash(evidence()?.stellaProperties?.['stellaops:stella.contentHash'] ?? '', 24) }} +
+ } + @if (evidence()?.stellaProperties?.['stellaops:composition.manifest']) { +
stellaops:composition.manifest
+
+ {{ evidence()?.stellaProperties?.['stellaops:composition.manifest'] }} +
+ } + @if (evidence()?.stellaProperties?.['stellaops:merkle.root']) { +
stellaops:merkle.root
+
+ {{ truncateHash(evidence()?.stellaProperties?.['stellaops:merkle.root'] ?? '', 24) }} +
+ } +
+
+ } + + + @if (evidence()?.verifiedAt) { +
+

Verification

+

+ Last verified: {{ formatDate(evidence()?.verifiedAt) }} +

+
+ } + + + @if (evidence()?.failureReason) { +
+

Failure Reason

+

{{ evidence()?.failureReason }}

+
+ } +
+ } +
+ `, + styles: [` + .determinism-badge { + border-radius: 8px; + overflow: hidden; + border: 1px solid #e5e7eb; + background: #fff; + + &.status-verified { + border-color: #86efac; + } + + &.status-pending { + border-color: #fcd34d; + } + + &.status-failed { + border-color: #fca5a5; + } + + &.status-unknown { + border-color: #d1d5db; + } + } + + .determinism-badge__header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1rem; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + transition: background-color 0.15s; + + &:hover { + background: #f9fafb; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: -2px; + } + + .status-verified & { + background: #f0fdf4; + &:hover { background: #dcfce7; } + } + + .status-pending & { + background: #fffbeb; + &:hover { background: #fef3c7; } + } + + .status-failed & { + background: #fef2f2; + &:hover { background: #fee2e2; } + } + } + + .determinism-badge__icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + font-size: 0.875rem; + font-weight: 700; + + .status-verified & { + background: #22c55e; + color: #fff; + } + + .status-pending & { + background: #f59e0b; + color: #fff; + } + + .status-failed & { + background: #ef4444; + color: #fff; + } + + .status-unknown & { + background: #6b7280; + color: #fff; + } + } + + .determinism-badge__label { + flex: 1; + } + + .determinism-badge__toggle { + font-size: 0.75rem; + color: #6b7280; + } + + .determinism-badge__details { + padding: 1rem; + border-top: 1px solid #e5e7eb; + background: #f9fafb; + } + + .details-section { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; + + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } + + &--error { + background: #fef2f2; + padding: 0.75rem; + border-radius: 6px; + border: 1px solid #fca5a5; + } + + &__title { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.025em; + } + } + + .merkle-root { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + } + + .hash-value, + .uri-value { + display: inline-block; + padding: 0.375rem 0.5rem; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 4px; + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.75rem; + word-break: break-all; + } + + .consistency-badge { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + + &.consistent { + background: #dcfce7; + color: #15803d; + } + + &.inconsistent { + background: #fee2e2; + color: #dc2626; + } + } + + .no-data { + font-size: 0.8125rem; + color: #6b7280; + font-style: italic; + } + + .manifest-info, + .stella-props { + margin: 0; + font-size: 0.8125rem; + + dt { + color: #6b7280; + margin-top: 0.5rem; + + &:first-child { + margin-top: 0; + } + } + + dd { + margin: 0.25rem 0 0; + color: #111827; + + code { + font-size: 0.75rem; + background: #fff; + padding: 0.125rem 0.375rem; + border: 1px solid #e5e7eb; + border-radius: 2px; + } + } + } + + .fragments-section { + margin-top: 0.75rem; + } + + .fragments-title { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + font-weight: 500; + color: #374151; + } + + .fragments-toggle { + padding: 0.25rem 0.5rem; + border: 1px solid #d1d5db; + border-radius: 4px; + background: #fff; + font-size: 0.75rem; + color: #374151; + cursor: pointer; + + &:hover { + background: #f3f4f6; + } + + &:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + } + + .fragments-list { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + } + + .fragment-item { + padding: 0.75rem; + border-radius: 6px; + margin-bottom: 0.5rem; + background: #fff; + border: 1px solid #e5e7eb; + + &:last-child { + margin-bottom: 0; + } + + &.fragment-verified { + border-color: #86efac; + } + + &.fragment-pending { + border-color: #fcd34d; + } + + &.fragment-failed { + border-color: #fca5a5; + } + } + + .fragment-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .fragment-status { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 700; + + .fragment-verified & { + background: #22c55e; + color: #fff; + } + + .fragment-pending & { + background: #f59e0b; + color: #fff; + } + + .fragment-failed & { + background: #ef4444; + color: #fff; + } + } + + .fragment-layer { + font-size: 0.8125rem; + font-weight: 500; + color: #374151; + } + + .fragment-details { + padding-left: 1.75rem; + } + + .fragment-row { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.25rem; + font-size: 0.75rem; + + &:last-child { + margin-bottom: 0; + } + } + + .fragment-label { + color: #6b7280; + white-space: nowrap; + } + + .fragment-hash { + font-family: 'Monaco', 'Consolas', monospace; + background: #f3f4f6; + padding: 0.125rem 0.25rem; + border-radius: 2px; + } + + .fragment-date { + color: #374151; + } + + .verified-at { + margin: 0; + font-size: 0.8125rem; + color: #374151; + } + + .failure-reason { + margin: 0; + font-size: 0.8125rem; + color: #dc2626; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeterminismBadgeComponent { + readonly evidence = input(null); + + readonly expanded = signal(false); + readonly showFragments = signal(false); + + readonly status = computed(() => { + return this.evidence()?.status ?? 'unknown'; + }); + + readonly statusClass = computed(() => { + return `status-${this.status()}`; + }); + + readonly statusLabel = computed(() => { + switch (this.status()) { + case 'verified': + return 'Verified'; + case 'pending': + return 'Pending'; + case 'failed': + return 'Failed'; + default: + return 'Unknown'; + } + }); + + toggleExpanded(): void { + this.expanded.update((v) => !v); + } + + toggleFragments(): void { + this.showFragments.update((v) => !v); + } + + getFragmentClass(fragment: FragmentAttestation): string { + return `fragment-${fragment.dsseStatus}`; + } + + formatDate(dateStr: string | undefined): string { + if (!dateStr) return 'N/A'; + try { + return new Date(dateStr).toLocaleString(); + } catch { + return dateStr; + } + } + + truncateHash(hash: string, length: number): string { + if (hash.length <= length) return hash; + return hash.slice(0, length) + '...'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/scans/entropy-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/scans/entropy-panel.component.ts new file mode 100644 index 000000000..d63298002 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/scans/entropy-panel.component.ts @@ -0,0 +1,950 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; + +import { + EntropyEvidence, + EntropyFile, + EntropyLayerSummary, + EntropyWindow, +} from '../../core/api/scanner.models'; + +type ViewMode = 'summary' | 'layers' | 'files'; + +@Component({ + selector: 'app-entropy-panel', + standalone: true, + imports: [CommonModule], + template: ` +
+ +
+
+

Entropy Analysis

+ @if (downloadUrl()) { + + Download Report + + } +
+ + + @if (layerSummary(); as summary) { +
+
+ Entropy Penalty + {{ (summary.entropyPenalty * 100).toFixed(1) }}% + max 30% +
+
+ Image Opaque Ratio + {{ (summary.imageOpaqueRatio * 100).toFixed(1) }}% + of total bytes +
+
+ Layers Analyzed + {{ summary.layers.length }} +
+
+ } +
+ + + + + +
+ + @if (viewMode() === 'summary') { +
+ + @if (layerSummary()?.layers?.length) { +
+

Layer Distribution

+ +
    + @for (segment of donutSegments(); track segment.digest) { +
  • + + {{ truncateHash(segment.digest, 12) }} + {{ (segment.ratio * 100).toFixed(1) }}% +
  • + } +
+
+ } + + + @if (allIndicators().length > 0) { +
+

Why Risky?

+
+ @for (indicator of allIndicators(); track indicator.name) { + + + {{ indicator.name }} + @if (indicator.count > 1) { + ({{ indicator.count }}) + } + + } +
+
+ } +
+ } + + + @if (viewMode() === 'layers') { +
+ @if (layerSummary()?.layers?.length) { +
    + @for (layer of layerSummary()?.layers ?? []; track layer.digest) { +
  • +
    + {{ truncateHash(layer.digest, 20) }} + + {{ (layer.opaqueRatio * 100).toFixed(1) }}% opaque + +
    +
    +
    +
    +
    + + {{ formatBytes(layer.opaqueBytes) }} / {{ formatBytes(layer.totalBytes) }} + + @if (layer.indicators.length > 0) { +
    + @for (ind of layer.indicators; track ind) { + {{ ind }} + } +
    + } +
    +
  • + } +
+ } @else { +

No layer entropy data available.

+ } +
+ } + + + @if (viewMode() === 'files') { +
+ @if (report()?.files?.length) { +
    + @for (file of report()?.files ?? []; track file.path) { +
  • + + + +
    + @for (window of file.windows; track window.offset) { +
    + } +
    + + @if (expandedFile() === file.path) { +
    +
    +
    Size:
    +
    {{ formatBytes(file.size) }}
    +
    Opaque bytes:
    +
    {{ formatBytes(file.opaqueBytes) }}
    +
    Opaque ratio:
    +
    {{ (file.opaqueRatio * 100).toFixed(2) }}%
    +
    + @if (file.flags.length > 0) { +
    + Flags: + @for (flag of file.flags; track flag) { + {{ flag }} + } +
    + } + @if (file.windows.length > 0) { +
    + High-entropy windows ({{ file.windows.length }}): + + + + + + + + + + @for (w of file.windows.slice(0, 10); track w.offset) { + + + + + + } + +
    OffsetLengthEntropy
    {{ w.offset }}{{ w.length }}{{ w.entropy.toFixed(3) }}
    + @if (file.windows.length > 10) { +

    + {{ file.windows.length - 10 }} more windows

    + } +
    + } +
    + } +
  • + } +
+ } @else { +

No file entropy data available.

+ } +
+ } +
+
+ `, + styles: [` + .entropy-panel { + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + overflow: hidden; + } + + .entropy-panel__header { + padding: 1rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + } + + .entropy-panel__title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + } + + .entropy-panel__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #111827; + } + + .entropy-panel__download { + padding: 0.375rem 0.75rem; + border: 1px solid #3b82f6; + border-radius: 4px; + background: #fff; + color: #3b82f6; + font-size: 0.8125rem; + text-decoration: none; + + &:hover { + background: #eff6ff; + } + } + + .entropy-panel__stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + } + + .stat-card { + padding: 0.75rem; + border-radius: 6px; + background: #fff; + border: 1px solid #e5e7eb; + text-align: center; + + &.severity-high { + border-color: #fca5a5; + background: #fef2f2; + } + + &.severity-medium { + border-color: #fcd34d; + background: #fffbeb; + } + + &.severity-low { + border-color: #86efac; + background: #f0fdf4; + } + } + + .stat-label { + display: block; + font-size: 0.6875rem; + text-transform: uppercase; + color: #6b7280; + letter-spacing: 0.025em; + } + + .stat-value { + display: block; + font-size: 1.5rem; + font-weight: 700; + color: #111827; + margin: 0.25rem 0; + } + + .stat-hint { + display: block; + font-size: 0.6875rem; + color: #9ca3af; + } + + .entropy-panel__nav { + display: flex; + border-bottom: 1px solid #e5e7eb; + background: #fff; + } + + .nav-tab { + flex: 1; + padding: 0.75rem 1rem; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + font-size: 0.875rem; + font-weight: 500; + color: #6b7280; + cursor: pointer; + + &:hover { + color: #374151; + } + + &.active { + color: #3b82f6; + border-bottom-color: #3b82f6; + } + } + + .entropy-panel__content { + padding: 1rem; + } + + // Donut Chart + .donut-section { + margin-bottom: 1.5rem; + + h4 { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + } + } + + .donut-chart { + display: flex; + justify-content: center; + margin-bottom: 1rem; + } + + .donut-svg { + width: 150px; + height: 150px; + } + + .donut-center-text { + font-size: 16px; + font-weight: 700; + fill: #111827; + } + + .donut-center-label { + font-size: 8px; + fill: #6b7280; + } + + .donut-legend { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.5rem; + } + + .legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + } + + .legend-color { + width: 12px; + height: 12px; + border-radius: 2px; + } + + .legend-label { + flex: 1; + font-family: monospace; + color: #374151; + } + + .legend-value { + font-weight: 500; + color: #111827; + } + + // Risk Chips + .indicators-section { + h4 { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + } + } + + .risk-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .risk-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: 9999px; + font-size: 0.8125rem; + font-weight: 500; + + &--high { + background: #fee2e2; + color: #dc2626; + } + + &--medium { + background: #fef3c7; + color: #d97706; + } + + &--low { + background: #e5e7eb; + color: #4b5563; + } + } + + .chip-icon { + font-size: 0.875rem; + } + + .chip-count { + font-size: 0.75rem; + opacity: 0.8; + } + + // Layers View + .layer-list { + list-style: none; + margin: 0; + padding: 0; + } + + .layer-item { + padding: 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } + + .layer-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + } + + .layer-digest { + font-size: 0.75rem; + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 2px; + } + + .layer-ratio { + font-size: 0.8125rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 4px; + + &.severity-high { + background: #fee2e2; + color: #dc2626; + } + + &.severity-medium { + background: #fef3c7; + color: #d97706; + } + + &.severity-low { + background: #dcfce7; + color: #15803d; + } + } + + .layer-bar-container { + height: 8px; + background: #e5e7eb; + border-radius: 4px; + overflow: hidden; + margin-bottom: 0.5rem; + } + + .layer-bar { + height: 100%; + border-radius: 4px; + transition: width 0.3s; + + &.severity-high { + background: #ef4444; + } + + &.severity-medium { + background: #f59e0b; + } + + &.severity-low { + background: #22c55e; + } + } + + .layer-details { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + } + + .layer-bytes { + font-size: 0.75rem; + color: #6b7280; + } + + .layer-indicators { + display: flex; + gap: 0.25rem; + } + + .indicator-tag { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + background: #f3f4f6; + border-radius: 2px; + color: #4b5563; + } + + // Files View + .file-list { + list-style: none; + margin: 0; + padding: 0; + } + + .file-item { + border: 1px solid #e5e7eb; + border-radius: 6px; + margin-bottom: 0.5rem; + overflow: hidden; + + &:last-child { + margin-bottom: 0; + } + + &.expanded { + border-color: #3b82f6; + } + } + + .file-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 0.75rem; + border: none; + background: #f9fafb; + cursor: pointer; + text-align: left; + + &:hover { + background: #f3f4f6; + } + } + + .file-path { + font-family: monospace; + font-size: 0.8125rem; + color: #374151; + word-break: break-all; + } + + .file-ratio { + font-size: 0.8125rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 4px; + margin-left: 0.5rem; + flex-shrink: 0; + + &.severity-high { + background: #fee2e2; + color: #dc2626; + } + + &.severity-medium { + background: #fef3c7; + color: #d97706; + } + + &.severity-low { + background: #dcfce7; + color: #15803d; + } + } + + .file-heatmap { + display: flex; + height: 8px; + background: #e5e7eb; + } + + .heatmap-cell { + flex: 1; + min-width: 2px; + } + + .file-details { + padding: 0.75rem; + background: #fff; + border-top: 1px solid #e5e7eb; + } + + .file-meta { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.75rem; + margin: 0 0 0.75rem; + font-size: 0.8125rem; + + dt { + color: #6b7280; + } + + dd { + margin: 0; + color: #111827; + } + } + + .file-flags { + margin-bottom: 0.75rem; + font-size: 0.8125rem; + + strong { + color: #374151; + margin-right: 0.5rem; + } + } + + .flag-tag { + display: inline-block; + margin-right: 0.25rem; + padding: 0.125rem 0.375rem; + background: #fef3c7; + border-radius: 2px; + font-size: 0.75rem; + color: #92400e; + } + + .file-windows { + font-size: 0.8125rem; + + strong { + display: block; + color: #374151; + margin-bottom: 0.5rem; + } + } + + .windows-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; + + th, td { + padding: 0.375rem 0.5rem; + text-align: left; + border-bottom: 1px solid #e5e7eb; + } + + th { + background: #f9fafb; + font-weight: 500; + color: #6b7280; + } + + td { + font-family: monospace; + } + } + + .more-windows { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: #6b7280; + font-style: italic; + } + + .empty-message { + text-align: center; + color: #6b7280; + font-style: italic; + padding: 2rem; + } + + .severity-high { + color: #dc2626; + } + + .severity-medium { + color: #d97706; + } + + .severity-low { + color: #15803d; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EntropyPanelComponent { + readonly evidence = input(null); + readonly download = output(); + + readonly viewMode = signal('summary'); + readonly expandedFile = signal(null); + + readonly report = computed(() => this.evidence()?.report ?? null); + readonly layerSummary = computed(() => this.evidence()?.layerSummary ?? null); + readonly downloadUrl = computed(() => this.evidence()?.downloadUrl ?? null); + + // Compute donut segments for layer visualization + readonly donutSegments = computed(() => { + const summary = this.layerSummary(); + if (!summary?.layers?.length) return []; + + const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4']; + const circumference = 2 * Math.PI * 40; + let offset = 0; + + return summary.layers.map((layer, i) => { + const ratio = layer.totalBytes / summary.layers.reduce((sum, l) => sum + l.totalBytes, 0); + const length = circumference * ratio; + const segment = { + digest: layer.digest, + ratio: layer.opaqueRatio, + color: colors[i % colors.length], + dasharray: `${length} ${circumference - length}`, + dashoffset: -offset, + label: `Layer ${i + 1}`, + }; + offset += length; + return segment; + }); + }); + + // Aggregate all indicators across layers + readonly allIndicators = computed(() => { + const summary = this.layerSummary(); + if (!summary?.layers?.length) return []; + + const indicatorMap = new Map(); + + const indicatorMeta: Record = { + 'packed': { severity: 'high', description: 'File appears to be packed/compressed', icon: '!' }, + 'no-symbols': { severity: 'medium', description: 'No debug symbols present', icon: '?' }, + 'stripped': { severity: 'medium', description: 'Binary has been stripped', icon: '-' }, + 'section:.UPX0': { severity: 'high', description: 'UPX packer detected', icon: '!' }, + 'section:.UPX1': { severity: 'high', description: 'UPX packer detected', icon: '!' }, + 'section:.aspack': { severity: 'high', description: 'ASPack packer detected', icon: '!' }, + }; + + for (const layer of summary.layers) { + for (const ind of layer.indicators) { + const existing = indicatorMap.get(ind); + if (existing) { + existing.count++; + } else { + const meta = indicatorMeta[ind] ?? { severity: 'low', description: ind, icon: '*' }; + indicatorMap.set(ind, { name: ind, count: 1, ...meta }); + } + } + } + + return Array.from(indicatorMap.values()).sort((a, b) => { + const severityOrder = { high: 0, medium: 1, low: 2 }; + return (severityOrder[a.severity as keyof typeof severityOrder] ?? 3) - + (severityOrder[b.severity as keyof typeof severityOrder] ?? 3); + }); + }); + + setViewMode(mode: ViewMode): void { + this.viewMode.set(mode); + } + + toggleFileExpanded(path: string): void { + const current = this.expandedFile(); + this.expandedFile.set(current === path ? null : path); + } + + getPenaltyClass(penalty: number): string { + if (penalty >= 0.2) return 'severity-high'; + if (penalty >= 0.1) return 'severity-medium'; + return 'severity-low'; + } + + getRatioClass(ratio: number): string { + if (ratio >= 0.3) return 'severity-high'; + if (ratio >= 0.15) return 'severity-medium'; + return 'severity-low'; + } + + getEntropyClass(entropy: number): string { + if (entropy >= 7.5) return 'severity-high'; + if (entropy >= 7.0) return 'severity-medium'; + return 'severity-low'; + } + + getEntropyColor(entropy: number): string { + // Map entropy (0-8) to color (green -> yellow -> red) + const normalized = Math.min(entropy / 8, 1); + if (normalized < 0.5) { + // Green to Yellow + const g = Math.round(255); + const r = Math.round(normalized * 2 * 255); + return `rgb(${r}, ${g}, 0)`; + } else { + // Yellow to Red + const r = 255; + const g = Math.round((1 - (normalized - 0.5) * 2) * 255); + return `rgb(${r}, ${g}, 0)`; + } + } + + formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + truncateHash(hash: string, length: number): string { + if (hash.length <= length) return hash; + return hash.slice(0, length) + '...'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/scans/entropy-policy-banner.component.ts b/src/Web/StellaOps.Web/src/app/features/scans/entropy-policy-banner.component.ts new file mode 100644 index 000000000..a42f3d495 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/scans/entropy-policy-banner.component.ts @@ -0,0 +1,659 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal, +} from '@angular/core'; + +import { EntropyEvidence } from '../../core/api/scanner.models'; + +export type PolicyDecisionKind = 'pass' | 'warn' | 'block'; + +export interface EntropyPolicyThresholds { + readonly blockImageOpaqueRatio: number; // Default 0.15 + readonly warnFileOpaqueRatio: number; // Default 0.30 + readonly maxEntropyPenalty: number; // Default 0.30 +} + +export interface EntropyPolicyResult { + readonly decision: PolicyDecisionKind; + readonly reasons: readonly string[]; + readonly mitigations: readonly string[]; + readonly thresholds: EntropyPolicyThresholds; +} + +const DEFAULT_THRESHOLDS: EntropyPolicyThresholds = { + blockImageOpaqueRatio: 0.15, + warnFileOpaqueRatio: 0.30, + maxEntropyPenalty: 0.30, +}; + +@Component({ + selector: 'app-entropy-policy-banner', + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [` + .entropy-policy-banner { + position: relative; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + + &.decision-pass { + background: #f0fdf4; + border: 1px solid #86efac; + } + + &.decision-warn { + background: #fffbeb; + border: 1px solid #fcd34d; + } + + &.decision-block { + background: #fef2f2; + border: 1px solid #fca5a5; + } + } + + .banner-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + .banner-icon { + font-size: 1.25rem; + + .decision-pass & { + color: #22c55e; + } + + .decision-warn & { + color: #f59e0b; + } + + .decision-block & { + color: #ef4444; + } + } + + .banner-title { + flex: 1; + margin: 0; + font-size: 1rem; + font-weight: 600; + + .decision-pass & { + color: #15803d; + } + + .decision-warn & { + color: #92400e; + } + + .decision-block & { + color: #dc2626; + } + } + + .banner-toggle { + padding: 0.25rem 0.5rem; + border: 1px solid currentColor; + border-radius: 4px; + background: transparent; + font-size: 0.75rem; + cursor: pointer; + opacity: 0.8; + + &:hover { + opacity: 1; + } + + .decision-pass & { + color: #15803d; + } + + .decision-warn & { + color: #92400e; + } + + .decision-block & { + color: #dc2626; + } + } + + .banner-summary { + margin: 0; + font-size: 0.875rem; + + .decision-pass & { + color: #166534; + } + + .decision-warn & { + color: #78350f; + } + + .decision-block & { + color: #991b1b; + } + } + + .banner-details { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid currentColor; + opacity: 0.3; + + .decision-pass & { + border-color: #86efac; + } + + .decision-warn & { + border-color: #fcd34d; + } + + .decision-block & { + border-color: #fca5a5; + } + } + + .details-section { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + + &--info { + background: rgba(255, 255, 255, 0.5); + padding: 0.75rem; + border-radius: 6px; + } + + h5 { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.025em; + } + } + + .reason-list, + .mitigation-list, + .suppression-list { + margin: 0; + padding-left: 1.25rem; + font-size: 0.8125rem; + color: #374151; + + li { + margin-bottom: 0.25rem; + + &:last-child { + margin-bottom: 0; + } + } + } + + .mitigation-list { + list-style: decimal; + } + + .threshold-list { + margin: 0; + font-size: 0.8125rem; + + dt { + color: #6b7280; + margin-top: 0.5rem; + + &:first-child { + margin-top: 0; + } + } + + dd { + margin: 0.25rem 0 0; + color: #111827; + } + } + + .threshold-value { + font-weight: 600; + padding: 0.125rem 0.375rem; + background: #e5e7eb; + border-radius: 4px; + + &.exceeded { + background: #fee2e2; + color: #dc2626; + } + } + + .current-value { + font-size: 0.75rem; + color: #6b7280; + margin-left: 0.5rem; + } + + .suppression-info { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + color: #374151; + } + + .evidence-download { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + border: 1px solid #3b82f6; + border-radius: 4px; + background: #fff; + color: #3b82f6; + font-size: 0.8125rem; + text-decoration: none; + cursor: pointer; + + &:hover { + background: #eff6ff; + } + + .download-icon { + font-size: 1rem; + } + } + + .evidence-hint { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: #6b7280; + } + + // Tooltip + .banner-tooltip-container { + position: absolute; + top: 1rem; + right: 1rem; + } + + .tooltip-trigger { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border: 1px solid #d1d5db; + border-radius: 50%; + background: #fff; + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + cursor: help; + + &:hover, + &:focus { + border-color: #3b82f6; + color: #3b82f6; + } + } + + .tooltip { + position: absolute; + top: 100%; + right: 0; + width: 280px; + margin-top: 0.5rem; + padding: 0.75rem; + background: #1f2937; + border-radius: 6px; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + z-index: 10; + + p { + margin: 0 0 0.5rem; + font-size: 0.75rem; + color: #e5e7eb; + line-height: 1.5; + + &:last-child { + margin-bottom: 0; + } + + strong { + color: #fff; + } + } + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EntropyPolicyBannerComponent { + readonly evidence = input(null); + readonly customThresholds = input>({}); + + readonly expanded = signal(false); + readonly showTooltip = signal(false); + + readonly thresholds = computed(() => ({ + ...DEFAULT_THRESHOLDS, + ...this.customThresholds(), + })); + + readonly downloadUrl = computed(() => this.evidence()?.downloadUrl ?? null); + + readonly maxFileOpaqueRatio = computed(() => { + const report = this.evidence()?.report; + if (!report?.files?.length) return null; + return Math.max(...report.files.map(f => f.opaqueRatio)); + }); + + readonly policyResult = computed(() => { + const ev = this.evidence(); + const thresholds = this.thresholds(); + const reasons: string[] = []; + const mitigations: string[] = []; + let decision: PolicyDecisionKind = 'pass'; + + if (!ev?.layerSummary) { + return { decision: 'pass', reasons: ['No entropy data available'], mitigations: [], thresholds }; + } + + const summary = ev.layerSummary; + const report = ev.report; + + // Check block condition: imageOpaqueRatio > threshold AND provenance unknown + if (summary.imageOpaqueRatio > thresholds.blockImageOpaqueRatio) { + decision = 'block'; + reasons.push( + `Image opaque ratio (${(summary.imageOpaqueRatio * 100).toFixed(1)}%) exceeds ` + + `block threshold (${(thresholds.blockImageOpaqueRatio * 100).toFixed(0)}%)` + ); + mitigations.push('Provide attestation of provenance for opaque binaries'); + mitigations.push('Unpack or decompress packed executables before scanning'); + } + + // Check warn condition: any file with opaqueRatio > threshold + if (report?.files) { + const highOpaqueFiles = report.files.filter(f => f.opaqueRatio > thresholds.warnFileOpaqueRatio); + if (highOpaqueFiles.length > 0) { + if (decision !== 'block') { + decision = 'warn'; + } + reasons.push( + `${highOpaqueFiles.length} file(s) exceed warn threshold ` + + `(${(thresholds.warnFileOpaqueRatio * 100).toFixed(0)}% opaque)` + ); + mitigations.push('Review high-entropy files for packed or obfuscated code'); + mitigations.push('Include debug symbols in builds where possible'); + } + } + + // Check for packed indicators + const packedLayers = summary.layers.filter(l => + l.indicators.some(i => i === 'packed' || i.startsWith('section:.UPX')) + ); + if (packedLayers.length > 0) { + if (decision !== 'block') { + decision = 'warn'; + } + reasons.push(`${packedLayers.length} layer(s) contain packed or compressed binaries`); + mitigations.push('Use uncompressed binaries or provide packer provenance'); + } + + // Check for stripped binaries without symbols + const strippedLayers = summary.layers.filter(l => + l.indicators.some(i => i === 'stripped' || i === 'no-symbols') + ); + if (strippedLayers.length > 0 && decision === 'pass') { + reasons.push(`${strippedLayers.length} layer(s) contain stripped binaries without symbols`); + // Only add mitigation if not already present + if (!mitigations.includes('Include debug symbols in builds where possible')) { + mitigations.push('Include debug symbols in builds where possible'); + } + } + + // Default pass reasons + if (decision === 'pass' && reasons.length === 0) { + reasons.push('All entropy metrics within acceptable thresholds'); + reasons.push(`Entropy penalty (${(summary.entropyPenalty * 100).toFixed(1)}%) is low`); + } + + return { decision, reasons, mitigations, thresholds }; + }); + + readonly bannerClass = computed(() => `decision-${this.policyResult().decision}`); + + readonly bannerTitle = computed(() => { + switch (this.policyResult().decision) { + case 'block': + return 'Entropy Policy: Blocked'; + case 'warn': + return 'Entropy Policy: Warning'; + case 'pass': + return 'Entropy Policy: Passed'; + } + }); + + readonly bannerSummary = computed(() => { + const result = this.policyResult(); + const ev = this.evidence(); + + switch (result.decision) { + case 'block': + return `This image is blocked due to high entropy/opaque content. ` + + `Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`; + case 'warn': + return `This image has elevated entropy metrics that may indicate packed or obfuscated code. ` + + `Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`; + case 'pass': + return `This image has acceptable entropy metrics. ` + + `Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`; + } + }); + + isBlockThresholdExceeded(): boolean { + const ratio = this.evidence()?.layerSummary?.imageOpaqueRatio; + if (ratio === undefined) return false; + return ratio > this.thresholds().blockImageOpaqueRatio; + } + + isWarnThresholdExceeded(): boolean { + const maxRatio = this.maxFileOpaqueRatio(); + if (maxRatio === null) return false; + return maxRatio > this.thresholds().warnFileOpaqueRatio; + } + + toggleExpanded(): void { + this.expanded.update(v => !v); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.html b/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.html index 197baf5bf..7b785b7a7 100644 --- a/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.html @@ -49,4 +49,31 @@

No attestation has been recorded for this scan.

+ + +
+

SBOM Determinism

+ @if (scan().determinism) { + + } @else { +

+ No determinism evidence available for this scan. +

+ } +
+ + +
+

Entropy Analysis

+ @if (scan().entropy) { + + + + + } @else { +

+ No entropy analysis available for this scan. +

+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.scss b/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.scss index a508e5e22..e788a3416 100644 --- a/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.scss @@ -77,3 +77,43 @@ font-style: italic; color: #94a3b8; } + +// Determinism Section +.determinism-section { + border: 1px solid #1f2933; + border-radius: 8px; + padding: 1.25rem; + background: #111827; + + h2 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + color: #e2e8f0; + } +} + +.determinism-empty { + font-style: italic; + color: #94a3b8; + margin: 0; +} + +// Entropy Section +.entropy-section { + border: 1px solid #1f2933; + border-radius: 8px; + padding: 1.25rem; + background: #111827; + + h2 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + color: #e2e8f0; + } +} + +.entropy-empty { + font-style: italic; + color: #94a3b8; + margin: 0; +} diff --git a/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.ts index 6b98c46a5..2f303bcc6 100644 --- a/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scans/scan-detail-page.component.ts @@ -8,6 +8,9 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { ScanAttestationPanelComponent } from './scan-attestation-panel.component'; +import { DeterminismBadgeComponent } from './determinism-badge.component'; +import { EntropyPanelComponent } from './entropy-panel.component'; +import { EntropyPolicyBannerComponent } from './entropy-policy-banner.component'; import { ScanDetail } from '../../core/api/scanner.models'; import { scanDetailWithFailedAttestation, @@ -24,7 +27,7 @@ const SCENARIO_MAP: Record = { @Component({ selector: 'app-scan-detail-page', standalone: true, - imports: [CommonModule, ScanAttestationPanelComponent], + imports: [CommonModule, ScanAttestationPanelComponent, DeterminismBadgeComponent, EntropyPanelComponent, EntropyPolicyBannerComponent], templateUrl: './scan-detail-page.component.html', styleUrls: ['./scan-detail-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html new file mode 100644 index 000000000..9185c5a70 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html @@ -0,0 +1,296 @@ +
+
+

Sources Dashboard

+

Attestation of Conformance (AOC) Metrics

+
+ + @if (loading()) { +
+
+

Loading dashboard...

+
+ } + + @if (!loading() && dashboard()) { + +
+ +
+
+

AOC Pass Rate

+ Last 24h +
+
+
+ {{ passRate() }}% + + {{ trendIcon() }} + {{ dashboard()?.passFail.trend }} + +
+
+
+ Passed + {{ formatNumber(dashboard()!.passFail.passed) }} +
+
+ Failed + {{ formatNumber(dashboard()!.passFail.failed) }} +
+
+ Pending + {{ formatNumber(dashboard()!.passFail.pending) }} +
+
+ +
+ @for (point of chartData(); track point.timestamp) { +
+ } +
+
+
+ + +
+
+

Recent Violations

+ @if (criticalViolations() > 0) { + {{ criticalViolations() }} critical + } +
+
+
    + @for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) { +
  • + + {{ violation.severity | uppercase }} + + {{ violation.code }} + {{ violation.name }} + {{ violation.count }} +
  • + } @empty { +
  • No recent violations
  • + } +
+
+
+ + +
+
+

Ingest Throughput

+ Last 24h +
+
+
+
+ {{ formatNumber(totalThroughput().docs) }} + Documents +
+
+ {{ formatBytes(totalThroughput().bytes) }} + Total Size +
+
+ + + + + + + + + + @for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) { + + + + + + } + +
TenantDocsRate
{{ tenant.tenantName }}{{ formatNumber(tenant.documentsIngested) }}{{ tenant.documentsPerMinute.toFixed(1) }}/min
+
+
+
+ + +
+
+

Sources

+ +
+ + + @if (verificationRequest()) { +
+
+ + @if (verificationRequest()!.status === 'completed') { + Verification Complete + } @else if (verificationRequest()!.status === 'running') { + Verification Running... + } @else { + Verification {{ verificationRequest()!.status | titlecase }} + } + + @if (verificationRequest()!.completedAt) { + {{ formatDate(verificationRequest()!.completedAt!) }} + } +
+ @if (verificationRequest()!.status === 'completed') { +
+
+ {{ verificationRequest()!.passed }} + Passed +
+
+ {{ verificationRequest()!.failed }} + Failed +
+
+ {{ verificationRequest()!.documentsVerified }} + Total +
+
+ } + @if (verificationRequest()!.cliCommand) { +
+ CLI Equivalent: + {{ verificationRequest()!.cliCommand }} +
+ } +
+ } + +
+ @for (source of dashboard()!.sources; track trackBySourceId($index, source)) { +
+
+ + @switch (source.type) { + @case ('registry') { 📦 } + @case ('pipeline') { 🔄 } + @case ('repository') { 📁 } + @case ('manual') { 📤 } + } + +
+

{{ source.name }}

+ {{ source.type | titlecase }} +
+ {{ source.status | titlecase }} +
+
+
+ {{ source.checkCount }} + Checks +
+
+ {{ (source.passRate * 100).toFixed(1) }}% + Pass Rate +
+
+ @if (source.recentViolations.length > 0) { +
+ Recent: + @for (v of source.recentViolations; track v.code) { + + {{ v.code }} + + } +
+ } +
+ Last check: {{ formatDate(source.lastCheck) }} +
+
+ } +
+
+ + + @if (selectedViolation()) { + + } + } +
diff --git a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss new file mode 100644 index 000000000..479884b65 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss @@ -0,0 +1,752 @@ +.aoc-dashboard { + display: grid; + gap: 1.5rem; + padding: 1.5rem; + color: #e2e8f0; + background: #0f172a; + min-height: calc(100vh - 120px); +} + +// Header +.dashboard-header { + h1 { + margin: 0; + font-size: 1.5rem; + } + + .subtitle { + margin: 0.25rem 0 0; + color: #94a3b8; + font-size: 0.875rem; + } +} + +// Loading +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: #94a3b8; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #334155; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +// Tiles +.tiles-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; +} + +.tile { + background: #111827; + border: 1px solid #1f2933; + border-radius: 8px; + overflow: hidden; +} + +.tile__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid #1f2933; + + h2 { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } +} + +.tile__period { + font-size: 0.75rem; + color: #64748b; +} + +.tile__content { + padding: 1.25rem; +} + +// Pass/Fail Tile +.pass-rate-display { + display: flex; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.pass-rate-value { + font-size: 3rem; + font-weight: 700; + line-height: 1; +} + +.rate--excellent { + color: #22c55e; +} + +.rate--good { + color: #84cc16; +} + +.rate--warning { + color: #eab308; +} + +.rate--critical { + color: #ef4444; +} + +.pass-rate-trend { + font-size: 1.5rem; + font-weight: 600; +} + +.trend--improving { + color: #22c55e; +} + +.trend--stable { + color: #94a3b8; +} + +.trend--degrading { + color: #ef4444; +} + +.pass-fail-stats { + display: flex; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.stat-label { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; +} + +.stat-value { + font-size: 1.125rem; + font-weight: 600; +} + +.stat--passed .stat-value { + color: #22c55e; +} + +.stat--failed .stat-value { + color: #ef4444; +} + +.stat--pending .stat-value { + color: #eab308; +} + +.mini-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: 40px; + margin-top: 0.75rem; +} + +.chart-bar { + flex: 1; + background: linear-gradient(to top, #3b82f6, #60a5fa); + border-radius: 2px 2px 0 0; + min-height: 4px; + transition: height 0.2s; + + &:hover { + background: linear-gradient(to top, #2563eb, #3b82f6); + } +} + +// Violations Tile +.critical-badge { + padding: 0.125rem 0.5rem; + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; +} + +.violations-list { + margin: 0; + padding: 0; + list-style: none; +} + +.violation-item { + display: grid; + grid-template-columns: auto auto 1fr auto; + gap: 0.75rem; + align-items: center; + padding: 0.625rem 0; + border-bottom: 1px solid #1f2933; + cursor: pointer; + transition: background 0.15s; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: rgba(255, 255, 255, 0.03); + } +} + +.violation-severity { + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.5625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.severity--critical { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.severity--high { + background: rgba(249, 115, 22, 0.2); + color: #f97316; +} + +.severity--medium { + background: rgba(234, 179, 8, 0.2); + color: #eab308; +} + +.severity--low { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; +} + +.severity--info { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.violation-code { + font-family: 'JetBrains Mono', monospace; + font-size: 0.75rem; + color: #94a3b8; +} + +.violation-name { + font-size: 0.8125rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.violation-count { + font-weight: 600; + color: #64748b; + font-size: 0.875rem; +} + +.no-violations { + color: #64748b; + font-style: italic; + padding: 1rem 0; + text-align: center; +} + +// Throughput Tile +.throughput-summary { + display: flex; + gap: 2rem; + margin-bottom: 1rem; +} + +.throughput-stat { + display: flex; + flex-direction: column; +} + +.throughput-value { + font-size: 1.75rem; + font-weight: 700; + color: #3b82f6; +} + +.throughput-label { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; +} + +.throughput-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + + th { + text-align: left; + padding: 0.5rem 0.25rem; + border-bottom: 1px solid #334155; + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + font-weight: 500; + } + + td { + padding: 0.5rem 0.25rem; + border-bottom: 1px solid #1f2933; + color: #cbd5e1; + } + + tr:last-child td { + border-bottom: none; + } +} + +// Sources Section +.sources-section { + margin-top: 0.5rem; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + + h2 { + margin: 0; + font-size: 1.125rem; + } +} + +.verify-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + background: #1d4ed8; + border: none; + border-radius: 4px; + color: #f8fafc; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; + + &:hover:not(:disabled) { + background: #1e40af; + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } +} + +.spinner-small { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +// Verification Result +.verification-result { + background: #111827; + border: 1px solid #334155; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; + + &--completed { + border-color: rgba(34, 197, 94, 0.3); + } +} + +.verification-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.verification-status { + font-weight: 600; + color: #22c55e; +} + +.verification-time { + font-size: 0.75rem; + color: #64748b; +} + +.verification-stats { + display: flex; + gap: 2rem; + margin-bottom: 0.75rem; +} + +.verification-stat { + display: flex; + flex-direction: column; + gap: 0.125rem; + + .stat-value { + font-size: 1.25rem; + font-weight: 700; + } + + .stat-label { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + } + + &--passed .stat-value { + color: #22c55e; + } + + &--failed .stat-value { + color: #ef4444; + } +} + +.cli-parity { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: #0f172a; + border-radius: 4px; + font-size: 0.8125rem; +} + +.cli-label { + color: #64748b; +} + +.cli-parity code { + font-family: 'JetBrains Mono', monospace; + color: #22c55e; +} + +// Sources Grid +.sources-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} + +.source-card { + background: #111827; + border: 1px solid #1f2933; + border-radius: 8px; + padding: 1rem; + transition: border-color 0.15s; + + &:hover { + border-color: #334155; + } +} + +.source-status--passed { + border-left: 3px solid #22c55e; +} + +.source-status--failed { + border-left: 3px solid #ef4444; +} + +.source-status--pending { + border-left: 3px solid #eab308; +} + +.source-status--skipped { + border-left: 3px solid #64748b; +} + +.source-header { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.source-icon { + font-size: 1.5rem; +} + +.source-info { + flex: 1; + + h3 { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + } +} + +.source-type { + font-size: 0.6875rem; + color: #64748b; + text-transform: uppercase; +} + +.source-status-badge { + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; +} + +.source-status--passed .source-status-badge { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.source-status--failed .source-status-badge { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.source-status--pending .source-status-badge { + background: rgba(234, 179, 8, 0.2); + color: #eab308; +} + +.source-stats { + display: flex; + gap: 1.5rem; + margin-bottom: 0.75rem; +} + +.source-stat { + display: flex; + flex-direction: column; +} + +.source-stat-value { + font-size: 1.125rem; + font-weight: 600; +} + +.source-stat-label { + font-size: 0.625rem; + text-transform: uppercase; + color: #64748b; +} + +.source-violations { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.source-violations-label { + font-size: 0.75rem; + color: #64748b; +} + +.source-violation-chip { + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.625rem; + font-family: 'JetBrains Mono', monospace; +} + +.source-last-check { + font-size: 0.6875rem; + color: #64748b; +} + +// Modal +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal-content { + background: #111827; + border: 1px solid #1f2933; + border-radius: 8px; + width: 100%; + max-width: 500px; + overflow: hidden; +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 1.25rem; + border-bottom: 1px solid #1f2933; + + h2 { + margin: 0; + font-size: 1.125rem; + line-height: 1.4; + } +} + +.modal-code { + font-family: 'JetBrains Mono', monospace; + color: #94a3b8; + margin-right: 0.5rem; +} + +.modal-close { + background: transparent; + border: none; + color: #64748b; + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + padding: 0; + + &:hover { + color: #e2e8f0; + } +} + +.modal-body { + padding: 1.25rem; +} + +.violation-severity-large { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + margin-bottom: 1rem; +} + +.violation-description { + margin: 0 0 1rem; + color: #94a3b8; + line-height: 1.5; +} + +.violation-meta { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin: 0 0 1rem; + + dt { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + margin-bottom: 0.125rem; + } + + dd { + margin: 0; + font-size: 0.9375rem; + } +} + +.docs-link { + display: inline-block; + color: #3b82f6; + font-size: 0.875rem; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.modal-footer { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding: 1rem 1.25rem; + border-top: 1px solid #1f2933; +} + +.btn { + padding: 0.625rem 1.25rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: all 0.15s; + border: none; + + &--primary { + background: #1d4ed8; + color: #f8fafc; + + &:hover { + background: #1e40af; + } + } + + &--secondary { + background: #334155; + color: #e2e8f0; + + &:hover { + background: #475569; + } + } +} + +// Screen reader only +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts new file mode 100644 index 000000000..581037926 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts @@ -0,0 +1,207 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { + AocDashboardSummary, + AocViolationCode, + IngestThroughput, + AocSource, + ViolationSeverity, + VerificationRequest, +} from '../../core/api/aoc.models'; +import { AOC_API, MockAocApi } from '../../core/api/aoc.client'; + +@Component({ + selector: 'app-aoc-dashboard', + standalone: true, + imports: [CommonModule, RouterModule], + providers: [{ provide: AOC_API, useClass: MockAocApi }], + templateUrl: './aoc-dashboard.component.html', + styleUrls: ['./aoc-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AocDashboardComponent implements OnInit { + private readonly aocApi = inject(AOC_API); + + // State + readonly dashboard = signal(null); + readonly loading = signal(true); + readonly verificationRequest = signal(null); + readonly verifying = signal(false); + readonly selectedViolation = signal(null); + + // Computed values + readonly passRate = computed(() => { + const dash = this.dashboard(); + return dash ? Math.round(dash.passFail.passRate * 100) : 0; + }); + + readonly passRateClass = computed(() => { + const rate = this.passRate(); + if (rate >= 95) return 'rate--excellent'; + if (rate >= 85) return 'rate--good'; + if (rate >= 70) return 'rate--warning'; + return 'rate--critical'; + }); + + readonly trendIcon = computed(() => { + const trend = this.dashboard()?.passFail.trend; + if (trend === 'improving') return '↑'; + if (trend === 'degrading') return '↓'; + return '→'; + }); + + readonly trendClass = computed(() => { + const trend = this.dashboard()?.passFail.trend; + if (trend === 'improving') return 'trend--improving'; + if (trend === 'degrading') return 'trend--degrading'; + return 'trend--stable'; + }); + + readonly totalThroughput = computed(() => { + const dash = this.dashboard(); + if (!dash) return { docs: 0, bytes: 0 }; + return dash.throughputByTenant.reduce( + (acc, t) => ({ + docs: acc.docs + t.documentsIngested, + bytes: acc.bytes + t.bytesIngested, + }), + { docs: 0, bytes: 0 } + ); + }); + + readonly criticalViolations = computed(() => { + const dash = this.dashboard(); + if (!dash) return 0; + return dash.recentViolations + .filter((v) => v.severity === 'critical') + .reduce((sum, v) => sum + v.count, 0); + }); + + readonly chartData = computed(() => { + const dash = this.dashboard(); + if (!dash) return []; + const history = dash.passFail.history; + const max = Math.max(...history.map((p) => p.value)); + return history.map((p) => ({ + timestamp: p.timestamp, + value: p.value, + height: (p.value / max) * 100, + })); + }); + + ngOnInit(): void { + this.loadDashboard(); + } + + private loadDashboard(): void { + this.loading.set(true); + this.aocApi.getDashboardSummary().subscribe({ + next: (summary) => { + this.dashboard.set(summary); + this.loading.set(false); + }, + error: (err) => { + console.error('Failed to load AOC dashboard:', err); + this.loading.set(false); + }, + }); + } + + startVerification(): void { + this.verifying.set(true); + this.verificationRequest.set(null); + + this.aocApi.startVerification().subscribe({ + next: (request) => { + this.verificationRequest.set(request); + // Poll for status updates (simplified - in real app would use interval) + setTimeout(() => this.pollVerificationStatus(request.requestId), 2000); + }, + error: (err) => { + console.error('Failed to start verification:', err); + this.verifying.set(false); + }, + }); + } + + private pollVerificationStatus(requestId: string): void { + this.aocApi.getVerificationStatus(requestId).subscribe({ + next: (request) => { + this.verificationRequest.set(request); + if (request.status === 'completed' || request.status === 'failed') { + this.verifying.set(false); + } + }, + error: (err) => { + console.error('Failed to get verification status:', err); + this.verifying.set(false); + }, + }); + } + + selectViolation(violation: AocViolationCode): void { + this.selectedViolation.set(violation); + } + + closeViolationDetail(): void { + this.selectedViolation.set(null); + } + + getSeverityClass(severity: ViolationSeverity): string { + return `severity--${severity}`; + } + + getSourceStatusClass(source: AocSource): string { + return `source-status--${source.status}`; + } + + formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + formatNumber(num: number): string { + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return num.toString(); + } + + formatDate(isoString: string): string { + try { + return new Date(isoString).toLocaleString(); + } catch { + return isoString; + } + } + + formatShortDate(isoString: string): string { + try { + const date = new Date(isoString); + return `${date.getMonth() + 1}/${date.getDate()}`; + } catch { + return ''; + } + } + + trackByCode(_index: number, violation: AocViolationCode): string { + return violation.code; + } + + trackByTenantId(_index: number, throughput: IngestThroughput): string { + return throughput.tenantId; + } + + trackBySourceId(_index: number, source: AocSource): string { + return source.sourceId; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/sources/index.ts b/src/Web/StellaOps.Web/src/app/features/sources/index.ts new file mode 100644 index 000000000..b516a4528 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/sources/index.ts @@ -0,0 +1,2 @@ +export { AocDashboardComponent } from './aoc-dashboard.component'; +export { ViolationDetailComponent } from './violation-detail.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/sources/violation-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/sources/violation-detail.component.ts new file mode 100644 index 000000000..71a69a009 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/sources/violation-detail.component.ts @@ -0,0 +1,527 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { + ViolationDetail, + ViolationSeverity, + OffendingField, +} from '../../core/api/aoc.models'; +import { AOC_API, MockAocApi } from '../../core/api/aoc.client'; + +@Component({ + selector: 'app-violation-detail', + standalone: true, + imports: [CommonModule, RouterModule], + providers: [{ provide: AOC_API, useClass: MockAocApi }], + template: ` +
+
+ ← Sources Dashboard +

+ {{ code() }} + Violation Details +

+
+ + @if (loading()) { +
+
+

Loading violations...

+
+ } + + @if (!loading() && violations().length === 0) { +
+

No violations found for code {{ code() }}

+
+ } + + @if (!loading() && violations().length > 0) { +
+ {{ violations().length }} occurrence(s) + + {{ violations()[0].severity | uppercase }} + +
+ +
+ @for (violation of violations(); track violation.violationId) { +
+
+
+ {{ violation.documentType | titlecase }} + {{ violation.documentId }} +
+ {{ formatDate(violation.detectedAt) }} +
+ + +
+

Offending Fields

+
+ @for (field of violation.offendingFields; track field.path) { +
+
+ Path: + {{ field.path }} +
+
+ @if (field.expectedValue) { +
+ Expected: + {{ field.expectedValue }} +
+ } +
+ Actual: + + {{ field.actualValue ?? '(missing)' }} + +
+
+
+ + {{ field.reason }} +
+
+ } +
+
+ + +
+

Provenance Metadata

+
+
+
Source Type
+
{{ violation.provenance.sourceType | titlecase }}
+
+
+
Source URI
+
{{ violation.provenance.sourceUri }}
+
+
+
Ingested At
+
{{ formatDate(violation.provenance.ingestedAt) }}
+
+
+
Ingested By
+
{{ violation.provenance.ingestedBy }}
+
+ @if (violation.provenance.buildId) { +
+
Build ID
+
{{ violation.provenance.buildId }}
+
+ } + @if (violation.provenance.commitSha) { +
+
Commit SHA
+
{{ violation.provenance.commitSha }}
+
+ } + @if (violation.provenance.pipelineUrl) { + + } +
+
+ + + @if (violation.suggestion) { +
+

Suggested Fix

+
+ +

{{ violation.suggestion }}

+
+
+ } +
+ } +
+ } +
+ `, + styles: [` + .violation-detail { + padding: 1.5rem; + color: #e2e8f0; + background: #0f172a; + min-height: calc(100vh - 120px); + } + + .detail-header { + margin-bottom: 1.5rem; + } + + .back-link { + display: inline-block; + margin-bottom: 0.75rem; + color: #94a3b8; + text-decoration: none; + font-size: 0.875rem; + + &:hover { + color: #e2e8f0; + } + } + + .detail-header h1 { + margin: 0; + font-size: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + } + + .violation-code { + font-family: 'JetBrains Mono', monospace; + color: #94a3b8; + } + + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: #94a3b8; + } + + .loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #334155; + border-top-color: #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .empty-state { + text-align: center; + padding: 3rem; + color: #64748b; + } + + .violation-summary { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .violation-count { + font-size: 1.125rem; + font-weight: 500; + } + + .severity-badge { + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + } + + .severity--critical { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + } + + .severity--high { + background: rgba(249, 115, 22, 0.2); + color: #f97316; + } + + .severity--medium { + background: rgba(234, 179, 8, 0.2); + color: #eab308; + } + + .severity--low { + background: rgba(100, 116, 139, 0.2); + color: #94a3b8; + } + + .violations-list { + display: grid; + gap: 1.5rem; + } + + .violation-card { + background: #111827; + border: 1px solid #1f2933; + border-radius: 8px; + overflow: hidden; + } + + .violation-card__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + background: #0f172a; + border-bottom: 1px solid #1f2933; + } + + .document-info { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .document-type { + padding: 0.125rem 0.5rem; + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + } + + .document-id { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8125rem; + color: #94a3b8; + } + + .detected-at { + font-size: 0.75rem; + color: #64748b; + } + + .offending-fields, + .provenance-section, + .suggestion-section { + padding: 1.25rem; + border-bottom: 1px solid #1f2933; + + &:last-child { + border-bottom: none; + } + + h3 { + margin: 0 0 1rem 0; + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + } + } + + .fields-list { + display: grid; + gap: 1rem; + } + + .field-item { + padding: 1rem; + background: #0f172a; + border: 1px solid #1f2933; + border-radius: 6px; + } + + .field-path { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + .path-label { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + } + + .field-path code { + font-family: 'JetBrains Mono', monospace; + font-size: 0.875rem; + color: #a855f7; + } + + .field-values { + display: grid; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + .expected, + .actual { + display: flex; + align-items: flex-start; + gap: 0.5rem; + } + + .value-label { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + min-width: 70px; + } + + .value-code { + font-family: 'JetBrains Mono', monospace; + font-size: 0.8125rem; + color: #22c55e; + word-break: break-all; + } + + .value-code--error { + color: #ef4444; + } + + .field-reason { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; + color: #fca5a5; + font-size: 0.8125rem; + } + + .reason-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + background: #ef4444; + color: #111827; + border-radius: 50%; + font-size: 0.625rem; + font-weight: bold; + } + + .provenance-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin: 0; + + dt { + font-size: 0.6875rem; + text-transform: uppercase; + color: #64748b; + margin-bottom: 0.25rem; + } + + dd { + margin: 0; + font-size: 0.875rem; + + code { + font-family: 'JetBrains Mono', monospace; + color: #94a3b8; + word-break: break-all; + } + + a { + color: #3b82f6; + text-decoration: none; + word-break: break-all; + + &:hover { + text-decoration: underline; + } + } + } + + .full-width { + grid-column: 1 / -1; + } + } + + .suggestion-content { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem; + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: 6px; + + p { + margin: 0; + color: #93c5fd; + line-height: 1.5; + } + } + + .suggestion-icon { + flex-shrink: 0; + color: #3b82f6; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViolationDetailComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly aocApi = inject(AOC_API); + + readonly code = signal(''); + readonly violations = signal([]); + readonly loading = signal(true); + + ngOnInit(): void { + const codeParam = this.route.snapshot.paramMap.get('code'); + if (codeParam) { + this.code.set(codeParam); + this.loadViolations(codeParam); + } + } + + private loadViolations(code: string): void { + this.loading.set(true); + this.aocApi.getViolationsByCode(code).subscribe({ + next: (violations) => { + this.violations.set(violations); + this.loading.set(false); + }, + error: (err) => { + console.error('Failed to load violations:', err); + this.loading.set(false); + }, + }); + } + + getSeverityClass(severity: ViolationSeverity): string { + return `severity--${severity}`; + } + + formatDate(isoString: string): string { + try { + return new Date(isoString).toLocaleString(); + } catch { + return isoString; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/testing/scan-fixtures.ts b/src/Web/StellaOps.Web/src/app/testing/scan-fixtures.ts index 05f274117..06eb756d4 100644 --- a/src/Web/StellaOps.Web/src/app/testing/scan-fixtures.ts +++ b/src/Web/StellaOps.Web/src/app/testing/scan-fixtures.ts @@ -1,4 +1,229 @@ -import { ScanDetail } from '../core/api/scanner.models'; +import { DeterminismEvidence, EntropyEvidence, ScanDetail } from '../core/api/scanner.models'; + +// Mock determinism evidence for verified scan +const verifiedDeterminism: DeterminismEvidence = { + status: 'verified', + merkleRoot: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + merkleRootConsistent: true, + contentHash: 'sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', + verifiedAt: '2025-10-23T12:05:00Z', + compositionManifest: { + compositionUri: 'cas://stellaops/scans/scan-verified-001/_composition.json', + merkleRoot: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + fragmentCount: 3, + createdAt: '2025-10-20T18:22:00Z', + fragments: [ + { + layerDigest: 'sha256:layer1abc123def456789012345678901234567890abcdef12345678901234', + fragmentSha256: 'sha256:frag1111111111111111111111111111111111111111111111111111111111', + dsseEnvelopeSha256: 'sha256:dsse1111111111111111111111111111111111111111111111111111111111', + dsseStatus: 'verified', + verifiedAt: '2025-10-23T12:04:55Z', + }, + { + layerDigest: 'sha256:layer2def456abc789012345678901234567890abcdef12345678901234', + fragmentSha256: 'sha256:frag2222222222222222222222222222222222222222222222222222222222', + dsseEnvelopeSha256: 'sha256:dsse2222222222222222222222222222222222222222222222222222222222', + dsseStatus: 'verified', + verifiedAt: '2025-10-23T12:04:56Z', + }, + { + layerDigest: 'sha256:layer3ghi789jkl012345678901234567890abcdef12345678901234', + fragmentSha256: 'sha256:frag3333333333333333333333333333333333333333333333333333333333', + dsseEnvelopeSha256: 'sha256:dsse3333333333333333333333333333333333333333333333333333333333', + dsseStatus: 'verified', + verifiedAt: '2025-10-23T12:04:57Z', + }, + ], + }, + stellaProperties: { + 'stellaops:stella.contentHash': 'sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210', + 'stellaops:composition.manifest': 'cas://stellaops/scans/scan-verified-001/_composition.json', + 'stellaops:merkle.root': 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', + }, +}; + +// Mock determinism evidence for failed scan +const failedDeterminism: DeterminismEvidence = { + status: 'failed', + merkleRoot: 'sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + merkleRootConsistent: false, + verifiedAt: '2025-10-23T09:18:15Z', + failureReason: 'Merkle root mismatch: computed root does not match stored root. Fragment at layer sha256:layer2def... has inconsistent hash.', + compositionManifest: { + compositionUri: 'cas://stellaops/scans/scan-failed-002/_composition.json', + merkleRoot: 'sha256:expected000000000000000000000000000000000000000000000000000000', + fragmentCount: 2, + createdAt: '2025-10-19T07:14:30Z', + fragments: [ + { + layerDigest: 'sha256:layer1abc123fail456789012345678901234567890abcdef12345678901234', + fragmentSha256: 'sha256:fragfail11111111111111111111111111111111111111111111111111111', + dsseEnvelopeSha256: 'sha256:dssefail11111111111111111111111111111111111111111111111111111', + dsseStatus: 'verified', + verifiedAt: '2025-10-23T09:18:10Z', + }, + { + layerDigest: 'sha256:layer2def456fail789012345678901234567890abcdef12345678901234', + fragmentSha256: 'sha256:fragfail22222222222222222222222222222222222222222222222222222', + dsseEnvelopeSha256: 'sha256:dssefail22222222222222222222222222222222222222222222222222222', + dsseStatus: 'failed', + verifiedAt: '2025-10-23T09:18:12Z', + }, + ], + }, + stellaProperties: { + 'stellaops:stella.contentHash': 'sha256:mismatch0000000000000000000000000000000000000000000000000000', + 'stellaops:composition.manifest': 'cas://stellaops/scans/scan-failed-002/_composition.json', + 'stellaops:merkle.root': 'sha256:expected000000000000000000000000000000000000000000000000000000', + }, +}; + +// Mock entropy evidence for verified scan (low risk) +const verifiedEntropy: EntropyEvidence = { + layerSummary: { + schema: 'stellaops.entropy/layer-summary@1', + generatedAt: '2025-10-20T18:22:00Z', + imageDigest: 'sha256:9f92a8c39f8d4f7bb1a60f2be650b3019b9a1bb50d2da839efa9bf2a278a0071', + layers: [ + { + digest: 'sha256:base-layer-001', + opaqueBytes: 102400, + totalBytes: 10485760, + opaqueRatio: 0.01, + indicators: [], + }, + { + digest: 'sha256:app-layer-002', + opaqueBytes: 524288, + totalBytes: 5242880, + opaqueRatio: 0.10, + indicators: ['no-symbols'], + }, + { + digest: 'sha256:deps-layer-003', + opaqueBytes: 204800, + totalBytes: 2097152, + opaqueRatio: 0.10, + indicators: [], + }, + ], + imageOpaqueRatio: 0.05, + entropyPenalty: 0.03, + }, + report: { + schema: 'stellaops.entropy/report@1', + generatedAt: '2025-10-20T18:22:00Z', + imageDigest: 'sha256:9f92a8c39f8d4f7bb1a60f2be650b3019b9a1bb50d2da839efa9bf2a278a0071', + files: [ + { + path: '/usr/bin/app', + size: 2097152, + opaqueBytes: 209715, + opaqueRatio: 0.10, + flags: ['no-symbols'], + windows: [ + { offset: 0, length: 4096, entropy: 5.2 }, + { offset: 4096, length: 4096, entropy: 6.1 }, + { offset: 8192, length: 4096, entropy: 7.3 }, + { offset: 12288, length: 4096, entropy: 6.8 }, + ], + }, + { + path: '/usr/lib/libcrypto.so', + size: 1048576, + opaqueBytes: 52428, + opaqueRatio: 0.05, + flags: [], + windows: [ + { offset: 0, length: 4096, entropy: 4.5 }, + { offset: 4096, length: 4096, entropy: 5.8 }, + ], + }, + ], + }, + downloadUrl: '/api/v1/scans/scan-verified-001/entropy', +}; + +// Mock entropy evidence for failed scan (high risk) +const failedEntropy: EntropyEvidence = { + layerSummary: { + schema: 'stellaops.entropy/layer-summary@1', + generatedAt: '2025-10-19T07:14:30Z', + imageDigest: 'sha256:b0d6865de537e45bdd9dd72cdac02bc6f459f0e546ed9134e2afc2fccd6298e0', + layers: [ + { + digest: 'sha256:base-layer-fail-001', + opaqueBytes: 1048576, + totalBytes: 5242880, + opaqueRatio: 0.20, + indicators: ['stripped'], + }, + { + digest: 'sha256:packed-layer-fail-002', + opaqueBytes: 3145728, + totalBytes: 4194304, + opaqueRatio: 0.75, + indicators: ['packed', 'section:.UPX0', 'no-symbols'], + }, + ], + imageOpaqueRatio: 0.45, + entropyPenalty: 0.25, + }, + report: { + schema: 'stellaops.entropy/report@1', + generatedAt: '2025-10-19T07:14:30Z', + imageDigest: 'sha256:b0d6865de537e45bdd9dd72cdac02bc6f459f0e546ed9134e2afc2fccd6298e0', + files: [ + { + path: '/opt/app/suspicious_binary', + size: 3145728, + opaqueBytes: 2831155, + opaqueRatio: 0.90, + flags: ['packed', 'section:.UPX0', 'stripped', 'no-symbols'], + windows: [ + { offset: 0, length: 4096, entropy: 7.92 }, + { offset: 1024, length: 4096, entropy: 7.88 }, + { offset: 2048, length: 4096, entropy: 7.95 }, + { offset: 3072, length: 4096, entropy: 7.91 }, + { offset: 4096, length: 4096, entropy: 7.89 }, + { offset: 5120, length: 4096, entropy: 7.94 }, + { offset: 6144, length: 4096, entropy: 7.87 }, + { offset: 7168, length: 4096, entropy: 7.93 }, + { offset: 8192, length: 4096, entropy: 7.90 }, + { offset: 9216, length: 4096, entropy: 7.86 }, + { offset: 10240, length: 4096, entropy: 7.91 }, + { offset: 11264, length: 4096, entropy: 7.88 }, + ], + }, + { + path: '/opt/app/libblob.so', + size: 524288, + opaqueBytes: 314573, + opaqueRatio: 0.60, + flags: ['stripped', 'no-symbols'], + windows: [ + { offset: 0, length: 4096, entropy: 7.45 }, + { offset: 1024, length: 4096, entropy: 7.38 }, + { offset: 2048, length: 4096, entropy: 7.52 }, + { offset: 3072, length: 4096, entropy: 7.41 }, + ], + }, + { + path: '/usr/local/bin/helper', + size: 102400, + opaqueBytes: 30720, + opaqueRatio: 0.30, + flags: ['no-symbols'], + windows: [ + { offset: 0, length: 4096, entropy: 7.22 }, + { offset: 4096, length: 4096, entropy: 6.95 }, + ], + }, + ], + }, + downloadUrl: '/api/v1/scans/scan-failed-002/entropy', +}; export const scanDetailWithVerifiedAttestation: ScanDetail = { scanId: 'scan-verified-001', @@ -13,6 +238,8 @@ export const scanDetailWithVerifiedAttestation: ScanDetail = { checkedAt: '2025-10-23T12:04:52Z', statusMessage: 'Rekor transparency log inclusion proof verified.', }, + determinism: verifiedDeterminism, + entropy: verifiedEntropy, }; export const scanDetailWithFailedAttestation: ScanDetail = { @@ -27,4 +254,6 @@ export const scanDetailWithFailedAttestation: ScanDetail = { statusMessage: 'Verification failed: inclusion proof leaf hash mismatch at depth 4.', }, + determinism: failedDeterminism, + entropy: failedEntropy, }; diff --git a/tests/AirGap/StellaOps.AirGap.Time.Tests/TimeStatusServiceTests.cs b/tests/AirGap/StellaOps.AirGap.Time.Tests/TimeStatusServiceTests.cs index de1360ac9..6b6f5b7d7 100644 --- a/tests/AirGap/StellaOps.AirGap.Time.Tests/TimeStatusServiceTests.cs +++ b/tests/AirGap/StellaOps.AirGap.Time.Tests/TimeStatusServiceTests.cs @@ -29,6 +29,7 @@ public class TimeStatusServiceTests Assert.Equal(anchor, status.Anchor); Assert.True(status.Staleness.IsWarning); Assert.False(status.Staleness.IsBreach); + Assert.Equal(15, status.Staleness.AgeSeconds); var snap = telemetry.GetLatest("t1"); Assert.NotNull(snap); Assert.Equal(status.Staleness.AgeSeconds, snap!.AgeSeconds);