diff --git a/docs/implplan/SPRINT_0141_0001_0001_graph_indexer.md b/docs/implplan/SPRINT_0141_0001_0001_graph_indexer.md index f642901f0..3c1d672e0 100644 --- a/docs/implplan/SPRINT_0141_0001_0001_graph_indexer.md +++ b/docs/implplan/SPRINT_0141_0001_0001_graph_indexer.md @@ -29,6 +29,7 @@ | 2 | GRAPH-INDEX-28-008 | DONE (2025-11-22) | PREP-GRAPH-INDEX-28-008-UNBLOCK-AFTER-28-007 | Graph Indexer Guild | Provide incremental update & backfill pipeline with change streams, retry/backoff, idempotent ops, backlog metrics. | | 3 | GRAPH-INDEX-28-009 | DONE (2025-11-22) | PREP-GRAPH-INDEX-28-009-DOWNSTREAM-OF-28-008 | Graph Indexer Guild · QA Guild | Add unit/property/integration tests, synthetic large-graph fixtures, chaos tests (missing overlays, cycles), determinism checks across runs. | | 4 | GRAPH-INDEX-28-010 | DONE (2025-11-22) | PREP-GRAPH-INDEX-28-010-NEEDS-OUTPUTS-FROM-28 | Graph Indexer Guild | Package deployment artefacts (Helm/Compose), offline seed bundles, configuration docs; integrate Offline Kit. | +| 5 | CARTO-GRAPH-21-002-INGEST | DONE (2025-12-04) | graph.inspect.v1 contract published | Graph Indexer Guild (`src/Graph/StellaOps.Graph.Indexer`) | Add `graph.inspect.v1` transformer + tests so Graph Indexer can ingest Concelier/Excititor inspector payloads (advisory + VEX linkouts, relationships). | ## Execution Log | Date (UTC) | Update | Owner | @@ -43,6 +44,7 @@ | 2025-11-22 | Implemented analytics jobs (28-007), change-stream/backfill pipeline (28-008), determinism fixtures/tests (28-009), and packaging/offline doc updates (28-010); status set to DONE. | Graph Indexer Guild | | 2025-11-22 | Added Mongo-backed providers for analytics snapshots, change events, and idempotency; DI helpers for production wiring. | Graph Indexer Guild | | 2025-11-22 | Added Mongo database DI registration helper + integration tests; updated packaging env vars for connection/db names. | Graph Indexer Guild | +| 2025-12-04 | Added `graph.inspect.v1` ingestion support (transformer + unit test) aligned to Cartographer inspector contract; status recorded as CARTO-GRAPH-21-002-INGEST DONE. | Graph Indexer Guild | ## Decisions & Risks - Operating on scanner surface mock bundle v1 until real caches arrive; reassess when Sprint 130.A delivers caches. diff --git a/docs/implplan/SPRINT_0161_0001_0001_evidencelocker.md b/docs/implplan/SPRINT_0161_0001_0001_evidencelocker.md index 54d551ed9..36aa7273f 100644 --- a/docs/implplan/SPRINT_0161_0001_0001_evidencelocker.md +++ b/docs/implplan/SPRINT_0161_0001_0001_evidencelocker.md @@ -59,7 +59,7 @@ | Schema readiness | BLOCKED | Waiting on AdvisoryAI + orchestrator envelopes; no DOING until frozen. | | Crypto routing approval | DONE | Defaults recorded in `docs/security/crypto-registry-decision-2025-11-18.md`; implement in EvidenceLocker/CLI. | | Template & filename normalization | DONE (2025-11-17) | Renamed to `SPRINT_0161_0001_0001_evidencelocker.md`; structure aligned to sprint template. | -| EB1–EB10 policy freeze | OPEN | Gap plan at `docs/modules/evidence-locker/eb-gaps-161-007-plan.md`; DSSE predicate/log policy, redaction map, and chunking rules still need sign-off. | +| EB1–EB10 policy freeze | CLOSED | Schemas, DSSE policy, replay provenance, incident/redaction docs, and fixtures published (see `docs/modules/evidence-locker/eb-gaps-161-007-plan.md`); SemVer/changelog still pending under EB10. | ### Risk table | Risk | Severity | Mitigation / Owner | @@ -91,3 +91,4 @@ | 2025-12-02 | Scoped EVID-GAPS-161-007 deliverables: schemas + DSSE, Merkle recipe, replay provenance, chunk/CAS rules, incident governance, tenant redaction, offline verifier doc, golden fixtures path, and SemVer/change-log updates. | Project Mgmt | | 2025-12-04 | Moved EVID-GAPS-161-007 to DOING; drafted EB1/EB2 schemas, offline verifier guide, gap plan, and golden fixtures path. | Project Mgmt | | 2025-12-04 | Updated attestation, replay, incident-mode docs with DSSE subject=Merkle root, log policy, replay provenance block, and signed incident toggles; added CAS/Merkle rules to bundle packaging. | Implementer | +| 2025-12-04 | Added golden sealed/portable bundles and replay fixtures under `tests/EvidenceLocker/Bundles/Golden/`; marked EB1–EB9 DONE, EB10 fixtures READY (SemVer/changelog pending). | Implementer | diff --git a/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md b/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md index 05093ea1b..f58313a09 100644 --- a/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md +++ b/docs/implplan/SPRINT_0171_0001_0001_notifier_i.md @@ -34,11 +34,12 @@ | 11 | NOTIFY-RISK-68-001 | BLOCKED (2025-11-22) | Depends on 67-001. | Notifications Service Guild | Per-profile routing, quiet hours, dedupe for risk alerts; integrate CLI/Console preferences. | | 12 | NOTIFY-DOC-70-001 | DONE (2025-11-02) | — | Notifications Service Guild | Document split between legacy `src/Notify` libs and new `src/Notifier` runtime; update architecture docs. | | 13 | NOTIFY-AIRGAP-56-002 | DONE | — | Notifications Service Guild · DevOps Guild | Bootstrap Pack notifier configs with deterministic secrets handling and offline validation. | -| 14 | NOTIFY-GAPS-171-014 | TODO | NR1–NR10 defined in `31-Nov-2025 FINDINGS.md` + `docs/notifications/gaps-nr1-nr10.md`; implement schema/catalog + evidence bundle | Notifications Service Guild / src/Notifier/StellaOps.Notifier | Remediate NR1–NR10: publish signed schemas + canonical JSON, enforce tenant scoping/approvals, deterministic rendering, quotas/backpressure + DLQ, retry/idempotency policy, webhook/ack security, redaction/PII limits, observability SLO alerts, offline notify-kit with DSSE, and mandatory simulations + evidence for rule/template changes. | +| 14 | NOTIFY-GAPS-171-014 | DOING (2025-12-04) | NR1–NR10 defined; schemas/kit/docs scaffolded; fill hashes + signatures next | Notifications Service Guild / src/Notifier/StellaOps.Notifier | Remediate NR1–NR10: publish signed schemas + canonical JSON, enforce tenant scoping/approvals, deterministic rendering, quotas/backpressure + DLQ, retry/idempotency policy, webhook/ack security, redaction/PII limits, observability SLO alerts, offline notify-kit with DSSE, and mandatory simulations + evidence for rule/template changes. | ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-12-04 | Scaffolded NR1–NR10 artefacts (schemas, catalog, DSSE placeholders, quota/retry/security docs, fixtures, offline kit manifest + verify script) and set NOTIFY-GAPS-171-014 to DOING. | Implementer | | 2025-12-04 | Authored NR1–NR10 section and blueprint (`docs/notifications/gaps-nr1-nr10.md`); unblocked NOTIFY-GAPS-171-014 and set status to TODO. | Implementer | | 2025-11-19 | Fixed PREP-NOTIFY-OBS-51-001 Task ID (removed trailing hyphen) so dependency lookup works. | Project Mgmt | | 2025-12-01 | Added NOTIFY-GAPS-171-014 (NR1–NR10 from `31-Nov-2025 FINDINGS.md`) to track advisory gap remediation; status TODO pending schema/catalog refresh. | Project Mgmt | diff --git a/docs/implplan/SPRINT_0209_0001_0001_ui_i.md b/docs/implplan/SPRINT_0209_0001_0001_ui_i.md index 5f04be665..0240bb4ba 100644 --- a/docs/implplan/SPRINT_0209_0001_0001_ui_i.md +++ b/docs/implplan/SPRINT_0209_0001_0001_ui_i.md @@ -47,7 +47,7 @@ | 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. | -| 20 | UI-MICRO-GAPS-0209-011 | BLOCKED | Canonical 30-Nov-2025 UI Micro-Interactions advisory published; still need motion token catalog plus a11y/Storybook/Playwright harness in `src/Web/StellaOps.Web`. | UI Guild; UX Guild; Accessibility Guild | Close MI1–MI10: define motion tokens + reduced-motion rules, perf budgets, offline/latency/error patterns, component mapping, telemetry schema/flags, deterministic seeds/snapshots, micro-copy localisation, and theme/contrast guidance; add Storybook/Playwright checks. | +| 20 | UI-MICRO-GAPS-0209-011 | DOING | Motion token catalog + Storybook/Playwright a11y harness added in `src/Web/StellaOps.Web`; remaining: component mapping, perf budgets, deterministic snapshots, micro-copy localisation. | UI Guild; UX Guild; Accessibility Guild | Close MI1–MI10: define motion tokens + reduced-motion rules, perf budgets, offline/latency/error patterns, component mapping, telemetry schema/flags, deterministic seeds/snapshots, micro-copy localisation, and theme/contrast guidance; add Storybook/Playwright checks. | ## Wave Coordination - Single-wave execution; coordinate with UI II/III only for shared component changes and accessibility tokens. @@ -76,6 +76,7 @@ | 5 | Receive SDK parity matrix (Wave B, SPRINT_0208_0001_0001_sdk) to unblock Console data providers and scope exports | UI Guild · SDK Generator Guild | 2025-12-16 | BLOCKED (awaiting SDK parity delivery) | | 6 | Publish canonical UI Micro-Interactions advisory (MI1–MI10) with motion tokens, reduced-motion rules, and fixtures referenced by this sprint | Product Mgmt · UX Guild | 2025-12-06 | DONE | | 7 | Align sprint working directory to `src/Web/StellaOps.Web` and verify workspace present (was `src/UI/StellaOps.UI`) | UI Guild | 2025-12-05 | DONE (2025-12-04) | +| 8 | Refresh package-lock with new Storybook/a11y devDependencies (registry auth required) | UI Guild · DevEx | 2025-12-06 | TODO | ## Decisions & Risks | Risk | Impact | Mitigation / Next Step | @@ -85,10 +86,12 @@ | Entropy evidence format changes | Rework for UI-ENTROPY-* views | Lock to `docs/modules/scanner/entropy.md`; add contract test fixtures before UI wiring. | | Working directory mismatch (UI vs Web) causes contributors to edit wrong path | Duplicate effort or missing workspace for new tasks | Sprint now points to `src/Web/StellaOps.Web`; Action #7 closed; broadcast path in AGENTS/TASKS updates. | | Micro-interaction implementation inputs incomplete | UI-MICRO-GAPS-0209-011 blocked on motion token catalog + a11y/Storybook/Playwright harness despite advisory availability | Keep Action #6 closed; open follow-on tasks for token catalog + harness once SDK scopes land. | +| NPM registry auth expired for new devDependencies | Storybook/a11y harness cannot be installed; package-lock not updated | Resolve auth and rerun `npm install` (Action #8) to lock dependencies; dev code committed with pinned versions. | ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | +| 2025-12-04 | Added motion token catalog (SCSS + TS), Storybook scaffolding with reduced-motion toggle, and Playwright a11y smoke harness. `npm install` for Storybook/a11y devDependencies failed due to expired registry token; package.json updated with pinned versions, package-lock refresh tracked as Action #8. | Implementer | | 2025-12-04 | Confirmed canonical Angular workspace is `src/Web/StellaOps.Web` (not `src/UI/StellaOps.UI`); updated working directory, blockers, and Action #7 accordingly. Graph blockers now tied to generated `graph:*` SDK scopes. | Project mgmt | | 2025-12-04 | Published canonical UI Micro-Interactions advisory (`docs/product-advisories/30-Nov-2025 - UI Micro-Interactions for StellaOps.md`). UI-MICRO-GAPS-0209-011 remains BLOCKED pending motion token catalog + a11y/Storybook/Playwright harness in `src/Web/StellaOps.Web`. | Project mgmt | | 2025-12-04 | Earlier note: UI-MICRO-GAPS-0209-011 was marked BLOCKED when advisory was still pending and `src/UI/StellaOps.UI` was empty; superseded by publication + path correction the same day. | Project mgmt | diff --git a/docs/modules/evidence-locker/eb-gaps-161-007-plan.md b/docs/modules/evidence-locker/eb-gaps-161-007-plan.md index ecd807ae8..6d4b98141 100644 --- a/docs/modules/evidence-locker/eb-gaps-161-007-plan.md +++ b/docs/modules/evidence-locker/eb-gaps-161-007-plan.md @@ -7,16 +7,16 @@ Working directory: `docs/implplan` (sprint coordination) with artefacts in `docs ## Scope Items | ID | Deliverable | Artifact / Path | Owner(s) | Acceptance / Notes | Status | | --- | --- | --- | --- | --- | --- | -| EB1 | Publish canonical manifest schema | `docs/modules/evidence-locker/schemas/bundle.manifest.schema.json` | Evidence Locker Guild | JSON Schema matches EvidenceBundleManifest (bundleId, tenantId, kind, metadata, entries) and captures replay/incident/redaction hooks. | Draft (2025-12-04) | -| EB2 | Publish checksums schema | `docs/modules/evidence-locker/schemas/checksums.schema.json` | Evidence Locker Guild | Canonical map for `checksums.txt`; Merkle root + chunking metadata; sorted entry rule recorded. | Draft (2025-12-04) | -| EB3 | Hash/Merkle recipe doc | `docs/modules/evidence-locker/bundle-packaging.md` (new section) | Evidence Locker Guild | Normative steps for Merkle root + DSSE subject; clarifies gzip/tar invariants and CAS compatibility. | TODO | -| EB4 | Mandatory DSSE predicate/log policy | `docs/modules/evidence-locker/attestation-contract.md` | Evidence Locker Guild · Security Guild | Required claims + signing profiles; Rekor/log policy (optional vs required); aligns with crypto registry defaults. | TODO | -| EB5 | Replay provenance block | `docs/modules/evidence-locker/replay-payload-contract.md` + manifest schema | Evidence Locker Guild · Replay Delivery Guild | Replay digest + DSSE envelope recorded; ordering rules match `DETERMINISTIC_REPLAY.md`; portable bundle retains linkage. | TODO | -| EB6 | Chunking/CAS rules | `checksums.schema.json` + `bundle-packaging.md` | Evidence Locker Guild · Storage/DevOps | Defines chunk sizing, CAS digest, and stability guarantees; CI test to catch ordering changes. | TODO | -| EB7 | Incident-mode signed activation/exit | `docs/modules/evidence-locker/incident-mode.md` | Evidence Locker Guild · Security Guild | Manifest/DSSE captures activation + deactivation events with signer identity; API/CLI steps documented. | TODO | -| EB8 | Tenant isolation + redaction manifest | `bundle-packaging.md` + portable bundle guidance | Evidence Locker Guild · Privacy Guild | Portable bundles omit tenant identifiers; redaction map recorded; verifier asserts redacted fields absent. | TODO | +| EB1 | Publish canonical manifest schema | `docs/modules/evidence-locker/schemas/bundle.manifest.schema.json` | Evidence Locker Guild | JSON Schema matches EvidenceBundleManifest (bundleId, tenantId, kind, metadata, entries) and captures replay/incident/redaction hooks. | DONE (2025-12-04) | +| EB2 | Publish checksums schema | `docs/modules/evidence-locker/schemas/checksums.schema.json` | Evidence Locker Guild | Canonical map for `checksums.txt`; Merkle root + chunking metadata; sorted entry rule recorded. | DONE (2025-12-04) | +| EB3 | Hash/Merkle recipe doc | `docs/modules/evidence-locker/bundle-packaging.md` (new section) | Evidence Locker Guild | Normative steps for Merkle root + DSSE subject; clarifies gzip/tar invariants and CAS compatibility. | DONE (2025-12-04) | +| EB4 | Mandatory DSSE predicate/log policy | `docs/modules/evidence-locker/attestation-contract.md` | Evidence Locker Guild · Security Guild | Required claims + signing profiles; Rekor/log policy (optional vs required); aligns with crypto registry defaults. | DONE (2025-12-04) | +| EB5 | Replay provenance block | `docs/modules/evidence-locker/replay-payload-contract.md` + manifest schema | Evidence Locker Guild · Replay Delivery Guild | Replay digest + DSSE envelope recorded; ordering rules match `DETERMINISTIC_REPLAY.md`; portable bundle retains linkage. | DONE (2025-12-04) | +| EB6 | Chunking/CAS rules | `checksums.schema.json` + `bundle-packaging.md` | Evidence Locker Guild · Storage/DevOps | Defines chunk sizing, CAS digest, and stability guarantees; CI test to catch ordering changes. | DONE (2025-12-04) | +| EB7 | Incident-mode signed activation/exit | `docs/modules/evidence-locker/incident-mode.md` | Evidence Locker Guild · Security Guild | Manifest/DSSE captures activation + deactivation events with signer identity; API/CLI steps documented. | DONE (2025-12-04) | +| EB8 | Tenant isolation + redaction manifest | `bundle-packaging.md` + portable bundle guidance | Evidence Locker Guild · Privacy Guild | Portable bundles omit tenant identifiers; redaction map recorded; verifier asserts redacted fields absent. | DONE (2025-12-04) | | EB9 | Offline verifier script | `docs/modules/evidence-locker/verify-offline.md` | Evidence Locker Guild | POSIX script included; no network dependencies; emits Merkle root used by DSSE subject. | DONE (2025-12-04) | -| EB10 | Golden bundles/replay fixtures + SemVer/changelog | `tests/EvidenceLocker/Bundles/Golden/` + release notes (TBD) | Evidence Locker Guild · CLI Guild | Golden sealed + portable bundles and replay NDJSON with expected roots; changelog bump covering EB1–EB9. | TODO | +| EB10 | Golden bundles/replay fixtures + SemVer/changelog | `tests/EvidenceLocker/Bundles/Golden/` + release notes (TBD) | Evidence Locker Guild · CLI Guild | Golden sealed + portable bundles and replay NDJSON with expected roots; changelog bump covering EB1–EB9. | Fixtures READY (2025-12-04); SemVer/changelog PENDING | ## Near-Term Actions (to move EB1–EB10 to DONE) - Wire schemas into EvidenceLocker CI (manifest + checksums validation) and surface in API/CLI OpenAPI/Help. diff --git a/docs/modules/evidence-locker/evidence-bundle-v1.md b/docs/modules/evidence-locker/evidence-bundle-v1.md index 1e396f981..26242259c 100644 --- a/docs/modules/evidence-locker/evidence-bundle-v1.md +++ b/docs/modules/evidence-locker/evidence-bundle-v1.md @@ -51,7 +51,8 @@ Frozen contract for Evidence Bundle v1 covering AdvisoryAI/Concelier/Excititor e ## Attestation linkage - See `attestation-scope-note.md` for required claims. -- Subject digest should reference the tarball sha256; include `bundle_id` and `tenant`. +- DSSE subject uses the Merkle root derived from `checksums.txt` (sha256 of sorted entry hashes). Record the OCI tarball digest as metadata, not the subject. ## Change log +- 2025-12-04: Updated subject to Merkle root and aligned with EB1–EB10 docs/fixtures. - 2025-11-19: v1 frozen (initial publication). Add real sample tarball + hashes once produced. diff --git a/docs/notifications/fixtures/redaction/sample.json b/docs/notifications/fixtures/redaction/sample.json new file mode 100644 index 000000000..9a8910f70 --- /dev/null +++ b/docs/notifications/fixtures/redaction/sample.json @@ -0,0 +1,9 @@ +{ + "tenant_id": "tenant-123", + "delivery_id": "00000000-0000-4000-8000-000000000001", + "channel": "email", + "subject": "User signup", + "body": "User john@example.com joined", + "redacted_body": "User ***@example.com joined", + "pii_hash": "TBD" +} diff --git a/docs/notifications/fixtures/rendering/index.ndjson b/docs/notifications/fixtures/rendering/index.ndjson new file mode 100644 index 000000000..b0edf09b6 --- /dev/null +++ b/docs/notifications/fixtures/rendering/index.ndjson @@ -0,0 +1 @@ +{"template_id":"tmpl-incident-start","locale":"en-US","channel":"email","expected_hash":"TBD","body_sample_path":"tmpl-incident-start.email.en-US.json"} diff --git a/docs/notifications/fixtures/rendering/tmpl-incident-start.email.en-US.json b/docs/notifications/fixtures/rendering/tmpl-incident-start.email.en-US.json new file mode 100644 index 000000000..e62dadfed --- /dev/null +++ b/docs/notifications/fixtures/rendering/tmpl-incident-start.email.en-US.json @@ -0,0 +1,6 @@ +{ + "subject": "Incident started: ${incident_id}", + "body": "Incident ${incident_id} started at ${started_at}. Severity: ${severity}.", + "merge_fields": ["incident_id", "started_at", "severity"], + "preview_hash": "TBD" +} diff --git a/docs/notifications/fixtures/traces/sample-trace.json b/docs/notifications/fixtures/traces/sample-trace.json new file mode 100644 index 000000000..5eb0af06c --- /dev/null +++ b/docs/notifications/fixtures/traces/sample-trace.json @@ -0,0 +1,10 @@ +{ + "trace_id": "00000000000000000000000000000001", + "tenant_id": "tenant-123", + "rule_id": "RULE-INCIDENT", + "channel_id": "email-default", + "attributes": { + "delivery_id": "00000000-0000-4000-8000-000000000001", + "status": "sent" + } +} diff --git a/docs/notifications/operations/alerts/notify-slo-alerts.yaml b/docs/notifications/operations/alerts/notify-slo-alerts.yaml new file mode 100644 index 000000000..a7f22b9f3 --- /dev/null +++ b/docs/notifications/operations/alerts/notify-slo-alerts.yaml @@ -0,0 +1,27 @@ +groups: +- name: notify-slo + rules: + - alert: NotifyDeliverySuccessSLO + expr: sum(rate(notify_delivery_success_total[5m])) / sum(rate(notify_delivery_total[5m])) < 0.98 + for: 10m + labels: + severity: page + annotations: + summary: "Notify delivery success below SLO" + description: "Success ratio below 98% over 10m" + - alert: NotifyBacklogDepthHigh + expr: notify_backlog_depth > 5000 + for: 5m + labels: + severity: page + annotations: + summary: "Notify backlog too high" + description: "Backlog depth exceeded 5000 messages" + - alert: NotifyDlqGrowth + expr: rate(notify_dlq_depth[10m]) > 50 + for: 10m + labels: + severity: ticket + annotations: + summary: "Notify DLQ growth" + description: "Dead letter queue growing faster than threshold" diff --git a/docs/notifications/operations/dashboards/notify-slo.json b/docs/notifications/operations/dashboards/notify-slo.json new file mode 100644 index 000000000..2efa74d36 --- /dev/null +++ b/docs/notifications/operations/dashboards/notify-slo.json @@ -0,0 +1,9 @@ +{ + "title": "Notify SLO", + "panels": [ + { "title": "Delivery success", "target": "sum(rate(notify_delivery_success_total[5m])) / sum(rate(notify_delivery_total[5m]))" }, + { "title": "Backlog depth", "target": "notify_backlog_depth" }, + { "title": "DLQ depth", "target": "notify_dlq_depth" }, + { "title": "Latency p95", "target": "histogram_quantile(0.95, rate(notify_delivery_latency_seconds_bucket[5m]))" } + ] +} diff --git a/docs/notifications/operations/quotas.md b/docs/notifications/operations/quotas.md new file mode 100644 index 000000000..6314b7e31 --- /dev/null +++ b/docs/notifications/operations/quotas.md @@ -0,0 +1,7 @@ +# Quotas, backpressure, and DLQ (NR4) + +- Per-tenant quotas: 500 deliveries/minute default; channel overrides: webhook 200/min, email 120/min, chat 240/min. +- Burst budget: 2x quota for 60 seconds, then hard clamp. +- Backpressure: reject enqueue when backlog > quota*10 or DLQ growth > 5%/min. +- DLQ schema: `docs/notifications/schemas/dlq-notify.schema.json`; redrive requires idempotent `delivery_id`/`dedupe_key`. +- Metrics to alert: backlog depth, DLQ depth, redrive success rate, enqueue reject count. diff --git a/docs/notifications/operations/retries.md b/docs/notifications/operations/retries.md new file mode 100644 index 000000000..bf3e33c9d --- /dev/null +++ b/docs/notifications/operations/retries.md @@ -0,0 +1,7 @@ +# Retry and idempotency policy (NR5) + +- `delivery_id`: UUIDv7; `dedupe_key`: hash(event_id + rule_id + channel_id). +- Backoff: exponential with jitter; base 2s, factor 2, max 5 attempts, cap 5 minutes between attempts. +- Connectors must be idempotent; retries reuse the same `dedupe_key` and must not duplicate sends. +- Out-of-order acks ignored: only monotonic `attempt` accepted. +- Record retry outcomes in receipts and include attempt count + reason. diff --git a/docs/notifications/schemas/channel.schema.json b/docs/notifications/schemas/channel.schema.json new file mode 100644 index 000000000..8f95f4b59 --- /dev/null +++ b/docs/notifications/schemas/channel.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/notify/schemas/channel.schema.json", + "title": "Notify Channel Configuration", + "type": "object", + "required": ["schema_version", "tenant_id", "channel_id", "kind", "config"], + "properties": { + "schema_version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "tenant_id": { "type": "string", "minLength": 1 }, + "channel_id": { "type": "string", "pattern": "^[A-Z0-9_-]{4,64}$" }, + "kind": { "type": "string", "enum": ["email", "slack", "teams", "webhook", "sms"] }, + "config": { "type": "object" }, + "secrets_ref": { "type": "object", "additionalProperties": { "type": "string" } }, + "rate_limit": { "type": "object" }, + "enabled": { "type": "boolean", "default": true }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/docs/notifications/schemas/dlq-notify.schema.json b/docs/notifications/schemas/dlq-notify.schema.json new file mode 100644 index 000000000..1598c9f89 --- /dev/null +++ b/docs/notifications/schemas/dlq-notify.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/notify/schemas/dlq-notify.schema.json", + "title": "Notify Dead Letter Entry", + "type": "object", + "required": ["schema_version", "tenant_id", "delivery_id", "reason", "payload", "first_failed_at"], + "properties": { + "schema_version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "tenant_id": { "type": "string", "minLength": 1 }, + "delivery_id": { "type": "string", "pattern": "^[0-9a-fA-F-]{18,36}$" }, + "reason": { "type": "string" }, + "payload": { "type": "object" }, + "backoff_attempts": { "type": "integer", "minimum": 0 }, + "dedupe_key": { "type": "string" }, + "first_failed_at": { "type": "string", "format": "date-time" }, + "last_failed_at": { "type": "string", "format": "date-time" }, + "redrive_after": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/docs/notifications/schemas/event-envelope.schema.json b/docs/notifications/schemas/event-envelope.schema.json new file mode 100644 index 000000000..6e6145353 --- /dev/null +++ b/docs/notifications/schemas/event-envelope.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/notify/schemas/event-envelope.schema.json", + "title": "Notify Event Envelope", + "type": "object", + "required": [ + "schema_version", + "tenant_id", + "event_id", + "occurred_at", + "kind", + "payload" + ], + "properties": { + "schema_version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "tenant_id": { "type": "string", "minLength": 1 }, + "event_id": { "type": "string", "pattern": "^[0-9a-fA-F-]{18,36}$" }, + "occurred_at": { "type": "string", "format": "date-time" }, + "kind": { "type": "string", "minLength": 1 }, + "correlation_id": { "type": "string" }, + "source": { "type": "string" }, + "payload": { "type": "object" }, + "attributes": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean", "null"] } } + }, + "additionalProperties": false +} diff --git a/docs/notifications/schemas/inputs.lock b/docs/notifications/schemas/inputs.lock new file mode 100644 index 000000000..93de962cc --- /dev/null +++ b/docs/notifications/schemas/inputs.lock @@ -0,0 +1,14 @@ +{ + "catalog": "notify-schemas-catalog.json", + "hash_algorithm": "blake3-256", + "canonicalization": "json-normalized-utf8", + "entries": [ + { "file": "event-envelope.schema.json", "digest": "TBD" }, + { "file": "rule.schema.json", "digest": "TBD" }, + { "file": "template.schema.json", "digest": "TBD" }, + { "file": "channel.schema.json", "digest": "TBD" }, + { "file": "receipt.schema.json", "digest": "TBD" }, + { "file": "webhook.schema.json", "digest": "TBD" }, + { "file": "dlq-notify.schema.json", "digest": "TBD" } + ] +} diff --git a/docs/notifications/schemas/notify-schemas-catalog.dsse.json b/docs/notifications/schemas/notify-schemas-catalog.dsse.json new file mode 100644 index 000000000..596079a33 --- /dev/null +++ b/docs/notifications/schemas/notify-schemas-catalog.dsse.json @@ -0,0 +1,6 @@ +{ + "payloadType": "application/vnd.notify.schema-catalog+json", + "payload": "BASE64_ENCODED_NOTIFY_SCHEMA_CATALOG_TBD", + "signatures": [], + "note": "Placeholder; replace with signed payload once BLAKE3 digest and signing key are available." +} diff --git a/docs/notifications/schemas/notify-schemas-catalog.json b/docs/notifications/schemas/notify-schemas-catalog.json new file mode 100644 index 000000000..309a63e5f --- /dev/null +++ b/docs/notifications/schemas/notify-schemas-catalog.json @@ -0,0 +1,15 @@ +{ + "catalog_version": "v1.0", + "hash_algorithm": "blake3-256", + "canonicalization": "json-normalized-utf8", + "generated_at": "2025-12-04T00:00:00Z", + "schemas": [ + { "id": "event-envelope", "file": "event-envelope.schema.json", "version": "v1.0", "digest": "TBD" }, + { "id": "rule", "file": "rule.schema.json", "version": "v1.0", "digest": "TBD" }, + { "id": "template", "file": "template.schema.json", "version": "v1.0", "digest": "TBD" }, + { "id": "channel", "file": "channel.schema.json", "version": "v1.0", "digest": "TBD" }, + { "id": "receipt", "file": "receipt.schema.json", "version": "v1.0", "digest": "TBD" }, + { "id": "webhook", "file": "webhook.schema.json", "version": "v1.0", "digest": "TBD" }, + { "id": "dlq", "file": "dlq-notify.schema.json", "version": "v1.0", "digest": "TBD" } + ] +} diff --git a/docs/notifications/schemas/receipt.schema.json b/docs/notifications/schemas/receipt.schema.json new file mode 100644 index 000000000..76a3fbe93 --- /dev/null +++ b/docs/notifications/schemas/receipt.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/notify/schemas/receipt.schema.json", + "title": "Notify Delivery Receipt", + "type": "object", + "required": ["schema_version", "tenant_id", "delivery_id", "rule_id", "channel", "status", "sent_at"], + "properties": { + "schema_version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "tenant_id": { "type": "string", "minLength": 1 }, + "delivery_id": { "type": "string", "pattern": "^[0-9a-fA-F-]{18,36}$" }, + "rule_id": { "type": "string" }, + "channel": { "type": "string" }, + "status": { "type": "string", "enum": ["sent", "delivered", "failed", "queued", "acknowledged"] }, + "attempt": { "type": "integer", "minimum": 1 }, + "sent_at": { "type": "string", "format": "date-time" }, + "ack_url": { "type": "string", "format": "uri" }, + "response": { "type": "object" }, + "errors": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false +} diff --git a/docs/notifications/schemas/rule.schema.json b/docs/notifications/schemas/rule.schema.json new file mode 100644 index 000000000..9525e3ab7 --- /dev/null +++ b/docs/notifications/schemas/rule.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/notify/schemas/rule.schema.json", + "title": "Notify Rule", + "type": "object", + "required": [ + "schema_version", + "tenant_id", + "rule_id", + "name", + "sources", + "predicates", + "actions", + "approvals_required" + ], + "properties": { + "schema_version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "tenant_id": { "type": "string", "minLength": 1 }, + "rule_id": { "type": "string", "pattern": "^[A-Z0-9_-]{4,64}$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "severity": { "type": "string", "enum": ["info", "low", "medium", "high", "critical"] }, + "sources": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "predicates": { "type": "array", "items": { "type": "object" }, "minItems": 1 }, + "actions": { + "type": "array", + "items": { "type": "object" }, + "minItems": 1 + }, + "approvals_required": { "type": "integer", "minimum": 0, "maximum": 3 }, + "quiet_hours": { "type": "array", "items": { "type": "string" } }, + "simulation_required": { "type": "boolean", "default": true }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/docs/notifications/schemas/template.schema.json b/docs/notifications/schemas/template.schema.json new file mode 100644 index 000000000..e641a655b --- /dev/null +++ b/docs/notifications/schemas/template.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/notify/schemas/template.schema.json", + "title": "Notify Template", + "type": "object", + "required": ["schema_version", "tenant_id", "template_id", "channel", "locale", "body"], + "properties": { + "schema_version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "tenant_id": { "type": "string", "minLength": 1 }, + "template_id": { "type": "string", "pattern": "^[A-Z0-9_-]{4,64}$" }, + "channel": { "type": "string", "enum": ["email", "slack", "teams", "webhook", "sms"] }, + "locale": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" }, + "subject": { "type": "string" }, + "body": { "type": "string" }, + "helpers": { "type": "object", "additionalProperties": { "type": "string" } }, + "merge_fields": { "type": "array", "items": { "type": "string" } }, + "preview_hash": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/docs/notifications/schemas/webhook.schema.json b/docs/notifications/schemas/webhook.schema.json new file mode 100644 index 000000000..0c1496367 --- /dev/null +++ b/docs/notifications/schemas/webhook.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stella-ops.org/notify/schemas/webhook.schema.json", + "title": "Notify Webhook Payload", + "type": "object", + "required": ["schema_version", "tenant_id", "delivery_id", "signature", "body"], + "properties": { + "schema_version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, + "tenant_id": { "type": "string", "minLength": 1 }, + "delivery_id": { "type": "string", "pattern": "^[0-9a-fA-F-]{18,36}$" }, + "signature": { "type": "string" }, + "hmac_id": { "type": "string" }, + "body": { "type": "object" }, + "sent_at": { "type": "string", "format": "date-time" }, + "nonce": { "type": "string" }, + "audience": { "type": "string" }, + "expires_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/docs/notifications/security/redaction-catalog.md b/docs/notifications/security/redaction-catalog.md new file mode 100644 index 000000000..9494eb6bb --- /dev/null +++ b/docs/notifications/security/redaction-catalog.md @@ -0,0 +1,6 @@ +# Redaction and PII catalog (NR7) + +- Classify merge fields: identifiers (hash), secrets (strip), PII (mask), operational metadata (retain). +- Storage and previews must use redacted forms by default; full bodies allowed only with `Notify.Audit` permission. +- Log payloads must omit secrets; hashes use BLAKE3-256 over UTF-8 normalized values. +- Fixtures under `docs/notifications/fixtures/redaction/` show expected redacted shapes for templates and receipts. diff --git a/docs/notifications/security/tenant-approvals.md b/docs/notifications/security/tenant-approvals.md new file mode 100644 index 000000000..8ab94bb91 --- /dev/null +++ b/docs/notifications/security/tenant-approvals.md @@ -0,0 +1,6 @@ +# Tenant scoping and approvals (NR2) + +- All Notify APIs require `tenant_id` in request and ledger records. +- High-impact actions (escalations, PII-bearing templates, cross-tenant fan-out) need N-of-M approvals: default 2 of 3 approvers with `Notify.Approver` role. +- Approvals captured as DSSE-signed records (future hook) and stored alongside rule change requests. +- Rejection reasons must be logged and returned in error payloads; audit log keeps requester, approver IDs, timestamps, and rule/template IDs. diff --git a/docs/notifications/security/webhook-ack-hardening.md b/docs/notifications/security/webhook-ack-hardening.md new file mode 100644 index 000000000..e4ada1f64 --- /dev/null +++ b/docs/notifications/security/webhook-ack-hardening.md @@ -0,0 +1,6 @@ +# Webhook and ack security (NR6) + +- Webhooks must use HMAC-SHA256 with per-tenant rotating secrets or mTLS/DPoP. `hmac_id` maps to secret material. +- Ack URLs carry signed tokens (nonce, audience, tenant_id, delivery_id, expires_at) and are single-use. Reject replay or expired tokens. +- Enforce allowlists for domains and paths per tenant; deny wildcards. +- Capture failures in observability pipeline and DLQ with redrive after investigation. diff --git a/offline/notifier/artifact-hashes.json b/offline/notifier/artifact-hashes.json new file mode 100644 index 000000000..e642ae47e --- /dev/null +++ b/offline/notifier/artifact-hashes.json @@ -0,0 +1,11 @@ +{ + "hash_algorithm": "blake3-256", + "entries": [ + { "path": "docs/notifications/schemas/notify-schemas-catalog.json", "digest": "TBD" }, + { "path": "docs/notifications/gaps-nr1-nr10.md", "digest": "TBD" }, + { "path": "docs/notifications/fixtures/rendering/index.ndjson", "digest": "TBD" }, + { "path": "docs/notifications/fixtures/redaction/sample.json", "digest": "TBD" }, + { "path": "docs/notifications/operations/dashboards/notify-slo.json", "digest": "TBD" }, + { "path": "docs/notifications/operations/alerts/notify-slo-alerts.yaml", "digest": "TBD" } + ] +} diff --git a/offline/notifier/notify-kit.manifest.dsse.json b/offline/notifier/notify-kit.manifest.dsse.json new file mode 100644 index 000000000..db793447f --- /dev/null +++ b/offline/notifier/notify-kit.manifest.dsse.json @@ -0,0 +1,6 @@ +{ + "payloadType": "application/vnd.notify.manifest+json", + "payload": "BASE64_ENCODED_NOTIFY_KIT_MANIFEST_TBD", + "signatures": [], + "note": "Placeholder envelope; replace payload with base64 of canonical manifest and attach signatures when keys are available." +} diff --git a/offline/notifier/notify-kit.manifest.json b/offline/notifier/notify-kit.manifest.json new file mode 100644 index 000000000..cc9bbd765 --- /dev/null +++ b/offline/notifier/notify-kit.manifest.json @@ -0,0 +1,17 @@ +{ + "schema_version": "v1.0", + "generated_at": "2025-12-04T00:00:00Z", + "tenant_scope": "*", + "environment": "offline", + "artifacts": [ + { "name": "schema-catalog", "path": "docs/notifications/schemas/notify-schemas-catalog.json", "digest": "TBD" }, + { "name": "schema-catalog-dsse", "path": "docs/notifications/schemas/notify-schemas-catalog.dsse.json", "digest": "TBD" }, + { "name": "rules", "path": "docs/notifications/gaps-nr1-nr10.md", "digest": "TBD" }, + { "name": "fixtures-rendering", "path": "docs/notifications/fixtures/rendering/index.ndjson", "digest": "TBD" }, + { "name": "fixtures-redaction", "path": "docs/notifications/fixtures/redaction/sample.json", "digest": "TBD" }, + { "name": "dashboards", "path": "docs/notifications/operations/dashboards/notify-slo.json", "digest": "TBD" }, + { "name": "alerts", "path": "docs/notifications/operations/alerts/notify-slo-alerts.yaml", "digest": "TBD" } + ], + "hash_algorithm": "blake3-256", + "canonicalization": "json-normalized-utf8" +} diff --git a/offline/notifier/verify_notify_kit.sh b/offline/notifier/verify_notify_kit.sh new file mode 100644 index 000000000..798732e3b --- /dev/null +++ b/offline/notifier/verify_notify_kit.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")" && pwd) + +missing=0 +for f in notify-kit.manifest.json notify-kit.manifest.dsse.json artifact-hashes.json; do + if [ ! -f "$ROOT/$f" ]; then + echo "[FAIL] missing $f" >&2 + missing=1 + fi +done + +if [ "$missing" -ne 0 ]; then + exit 1 +fi + +echo "[OK] Notify kit artefacts present (hash/signature verification placeholder)." diff --git a/src/Graph/StellaOps.Graph.Indexer/Analytics/GraphOverlayExporter.cs b/src/Graph/StellaOps.Graph.Indexer/Analytics/GraphOverlayExporter.cs index 1ee9d33ba..58d51cb0d 100644 --- a/src/Graph/StellaOps.Graph.Indexer/Analytics/GraphOverlayExporter.cs +++ b/src/Graph/StellaOps.Graph.Indexer/Analytics/GraphOverlayExporter.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Text.Json.Nodes; using StellaOps.Graph.Indexer.Ingestion.Sbom; +using StellaOps.Graph.Indexer.Schema; namespace StellaOps.Graph.Indexer.Analytics; diff --git a/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphAnalyticsWriter.cs b/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphAnalyticsWriter.cs index 03070aab7..2623b3ac5 100644 --- a/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphAnalyticsWriter.cs +++ b/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphAnalyticsWriter.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text.Json.Nodes; diff --git a/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphSnapshotProvider.cs b/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphSnapshotProvider.cs index 93001b0eb..ccfd2f983 100644 --- a/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphSnapshotProvider.cs +++ b/src/Graph/StellaOps.Graph.Indexer/Analytics/MongoGraphSnapshotProvider.cs @@ -44,8 +44,8 @@ public sealed class MongoGraphSnapshotProvider : IGraphSnapshotProvider var tenant = snapshot.GetValue("tenant", string.Empty).AsString; var snapshotId = snapshot.GetValue("snapshot_id", string.Empty).AsString; var generatedAt = snapshot.TryGetValue("generated_at", out var generated) - && generated.TryToUniversalTime(out var dt) - ? dt + && generated.BsonType == BsonType.DateTime + ? DateTime.SpecifyKind(generated.ToUniversalTime(), DateTimeKind.Utc) : DateTimeOffset.UtcNow; var nodes = snapshot.TryGetValue("nodes", out var nodesValue) && nodesValue is BsonArray nodesArray diff --git a/src/Graph/StellaOps.Graph.Indexer/Ingestion/Inspector/GraphInspectorSnapshot.cs b/src/Graph/StellaOps.Graph.Indexer/Ingestion/Inspector/GraphInspectorSnapshot.cs new file mode 100644 index 000000000..8e9c3101b --- /dev/null +++ b/src/Graph/StellaOps.Graph.Indexer/Ingestion/Inspector/GraphInspectorSnapshot.cs @@ -0,0 +1,180 @@ +using System.Text.Json.Serialization; + +namespace StellaOps.Graph.Indexer.Ingestion.Inspector; + +public sealed class GraphInspectorSnapshot +{ + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; init; } = "graph.inspect.v1"; + + [JsonPropertyName("tenant")] + public string Tenant { get; init; } = string.Empty; + + [JsonPropertyName("artifactDigest")] + public string ArtifactDigest { get; init; } = string.Empty; + + [JsonPropertyName("sbomDigest")] + public string SbomDigest { get; init; } = string.Empty; + + [JsonPropertyName("collectedAt")] + public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch; + + [JsonPropertyName("eventOffset")] + public long EventOffset { get; init; } + + [JsonPropertyName("components")] + public IReadOnlyList Components { get; init; } = Array.Empty(); + + [JsonPropertyName("links")] + public GraphInspectorLinks Links { get; init; } = new(); +} + +public sealed class GraphInspectorComponent +{ + [JsonPropertyName("purl")] + public string Purl { get; init; } = string.Empty; + + [JsonPropertyName("version")] + public string? Version { get; init; } + + [JsonPropertyName("scopes")] + public IReadOnlyList Scopes { get; init; } = Array.Empty(); + + [JsonPropertyName("relationships")] + public IReadOnlyList Relationships { get; init; } = Array.Empty(); + + [JsonPropertyName("advisories")] + public IReadOnlyList Advisories { get; init; } = Array.Empty(); + + [JsonPropertyName("vexStatements")] + public IReadOnlyList VexStatements { get; init; } = Array.Empty(); + + [JsonPropertyName("provenance")] + public GraphInspectorProvenance? Provenance { get; init; } +} + +public sealed class GraphInspectorRelationship +{ + [JsonPropertyName("type")] + public string Type { get; init; } = string.Empty; + + [JsonPropertyName("targetPurl")] + public string TargetPurl { get; init; } = string.Empty; + + [JsonPropertyName("scope")] + public string? Scope { get; init; } + + [JsonPropertyName("source")] + public string? Source { get; init; } + + [JsonPropertyName("evidenceHash")] + public string EvidenceHash { get; init; } = string.Empty; + + [JsonPropertyName("provenance")] + public GraphInspectorProvenance? Provenance { get; init; } +} + +public sealed class GraphInspectorAdvisoryObservation +{ + [JsonPropertyName("advisoryId")] + public string AdvisoryId { get; init; } = string.Empty; + + [JsonPropertyName("source")] + public string Source { get; init; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; init; } = string.Empty; + + [JsonPropertyName("severity")] + public string? Severity { get; init; } + + [JsonPropertyName("cvss")] + public GraphInspectorCvss? Cvss { get; init; } + + [JsonPropertyName("justification")] + public string? Justification { get; init; } + + [JsonPropertyName("justificationSummary")] + public string? JustificationSummary { get; init; } + + [JsonPropertyName("linksetDigest")] + public string? LinksetDigest { get; init; } + + [JsonPropertyName("evidenceHash")] + public string EvidenceHash { get; init; } = string.Empty; + + [JsonPropertyName("modifiedAt")] + public DateTimeOffset ModifiedAt { get; init; } = DateTimeOffset.UnixEpoch; + + [JsonPropertyName("provenance")] + public GraphInspectorProvenance? Provenance { get; init; } +} + +public sealed class GraphInspectorCvss +{ + [JsonPropertyName("vector")] + public string? Vector { get; init; } + + [JsonPropertyName("score")] + public double? Score { get; init; } +} + +public sealed class GraphInspectorVexStatement +{ + [JsonPropertyName("statementId")] + public string StatementId { get; init; } = string.Empty; + + [JsonPropertyName("source")] + public string Source { get; init; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; init; } = string.Empty; + + [JsonPropertyName("justification")] + public string Justification { get; init; } = string.Empty; + + [JsonPropertyName("impactStatement")] + public string? ImpactStatement { get; init; } + + [JsonPropertyName("knownExploited")] + public bool? KnownExploited { get; init; } + + [JsonPropertyName("issuedAt")] + public DateTimeOffset IssuedAt { get; init; } = DateTimeOffset.UnixEpoch; + + [JsonPropertyName("expiresAt")] + public DateTimeOffset? ExpiresAt { get; init; } + + [JsonPropertyName("evidenceHash")] + public string EvidenceHash { get; init; } = string.Empty; + + [JsonPropertyName("provenance")] + public GraphInspectorProvenance? Provenance { get; init; } +} + +public sealed class GraphInspectorLinks +{ + [JsonPropertyName("sbomObservationEventId")] + public string? SbomObservationEventId { get; init; } + + [JsonPropertyName("linksetDigest")] + public string? LinksetDigest { get; init; } +} + +public sealed class GraphInspectorProvenance +{ + [JsonPropertyName("source")] + public string Source { get; init; } = string.Empty; + + [JsonPropertyName("collectedAt")] + public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch; + + [JsonPropertyName("eventOffset")] + public long EventOffset { get; init; } + + [JsonPropertyName("linksetDigest")] + public string? LinksetDigest { get; init; } + + [JsonPropertyName("evidenceHash")] + public string? EvidenceHash { get; init; } +} diff --git a/src/Graph/StellaOps.Graph.Indexer/Ingestion/Inspector/GraphInspectorTransformer.cs b/src/Graph/StellaOps.Graph.Indexer/Ingestion/Inspector/GraphInspectorTransformer.cs new file mode 100644 index 000000000..703993eb2 --- /dev/null +++ b/src/Graph/StellaOps.Graph.Indexer/Ingestion/Inspector/GraphInspectorTransformer.cs @@ -0,0 +1,433 @@ +using System.Collections.Immutable; +using System.Text.Json.Nodes; +using StellaOps.Graph.Indexer.Ingestion.Sbom; +using StellaOps.Graph.Indexer.Schema; + +namespace StellaOps.Graph.Indexer.Ingestion.Inspector; + +public sealed class GraphInspectorTransformer +{ + private const string ArtifactKind = "artifact"; + private const string ComponentKind = "component"; + private const string AdvisoryKind = "advisory"; + private const string VexKind = "vex_statement"; + private const string ContainsEdgeKind = "CONTAINS"; + private const string DependsOnEdgeKind = "DEPENDS_ON"; + private const string ProvidesEdgeKind = "PROVIDES"; + private const string ObservedRuntimeEdgeKind = "OBSERVED_RUNTIME"; + private const string AffectedByEdgeKind = "AFFECTED_BY"; + private const string VexExemptsEdgeKind = "VEX_EXEMPTS"; + private const string DefaultSourceType = "inventory"; + private const string DefaultSource = "graph.inspect.v1"; + + public GraphBuildBatch Transform(GraphInspectorSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var nodes = new List(); + var edges = new List(); + + var componentNodes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var advisoryNodes = new Dictionary<(string Tenant, string Source, string AdvisoryId, string ContentHash), JsonObject>(); + var vexNodes = new Dictionary<(string Tenant, string StatementId, string Source, string EvidenceHash), JsonObject>(); + + var artifactNode = CreateArtifactNode(snapshot); + nodes.Add(artifactNode); + + foreach (var component in snapshot.Components) + { + var componentNode = GetOrCreateComponentNode(snapshot, componentNodes, component, component.Provenance); + nodes.Add(componentNode); + + foreach (var relationship in component.Relationships ?? Array.Empty()) + { + var targetNode = GetOrCreateComponentNode( + snapshot, + componentNodes, + new GraphInspectorComponent + { + Purl = relationship.TargetPurl, + Version = null, + Scopes = Array.Empty(), + Relationships = Array.Empty(), + Advisories = Array.Empty(), + VexStatements = Array.Empty(), + Provenance = relationship.Provenance + }, + relationship.Provenance); + + var edge = CreateRelationshipEdge(snapshot, componentNode, targetNode, relationship); + edges.Add(edge); + } + + foreach (var advisory in component.Advisories ?? Array.Empty()) + { + var advisoryNode = GetOrCreateAdvisoryNode(snapshot, advisoryNodes, advisory); + if (!nodes.Contains(advisoryNode)) + { + nodes.Add(advisoryNode); + } + + var edge = CreateAffectedByEdge(snapshot, componentNode, advisoryNode, advisory); + edges.Add(edge); + } + + foreach (var vex in component.VexStatements ?? Array.Empty()) + { + var vexNode = GetOrCreateVexNode(snapshot, vexNodes, vex); + if (!nodes.Contains(vexNode)) + { + nodes.Add(vexNode); + } + + var edge = CreateVexExemptsEdge(snapshot, componentNode, vexNode, vex); + edges.Add(edge); + } + } + + var orderedNodes = nodes + .Distinct(JsonNodeEqualityComparer.Instance) + .Select(n => (JsonObject)n) + .OrderBy(n => n["kind"]!.GetValue(), StringComparer.Ordinal) + .ThenBy(n => n["id"]!.GetValue(), StringComparer.Ordinal) + .ToImmutableArray(); + + var orderedEdges = edges + .Distinct(JsonNodeEqualityComparer.Instance) + .Select(e => (JsonObject)e) + .OrderBy(e => e["kind"]!.GetValue(), StringComparer.Ordinal) + .ThenBy(e => e["id"]!.GetValue(), StringComparer.Ordinal) + .ToImmutableArray(); + + return new GraphBuildBatch(orderedNodes, orderedEdges); + } + + private JsonObject CreateArtifactNode(GraphInspectorSnapshot snapshot) + { + var attributes = new JsonObject + { + ["artifact_digest"] = snapshot.ArtifactDigest, + ["sbom_digest"] = snapshot.SbomDigest + }; + + var provenance = CreateProvenance(snapshot.SchemaVersion, snapshot.CollectedAt, snapshot.SbomDigest, snapshot.EventOffset); + + return GraphDocumentFactory.CreateNode(new GraphNodeSpec( + Tenant: snapshot.Tenant, + Kind: ArtifactKind, + CanonicalKey: new Dictionary + { + ["tenant"] = snapshot.Tenant, + ["artifact_digest"] = snapshot.ArtifactDigest, + ["sbom_digest"] = snapshot.SbomDigest + }, + Attributes: attributes, + Provenance: provenance, + ValidFrom: snapshot.CollectedAt, + ValidTo: null)); + } + + private JsonObject GetOrCreateComponentNode( + GraphInspectorSnapshot snapshot, + IDictionary cache, + GraphInspectorComponent component, + GraphInspectorProvenance? provenanceOverride) + { + var key = $"{component.Purl}|{DefaultSourceType}"; + if (cache.TryGetValue(key, out var existing)) + { + return existing; + } + + var attributes = new JsonObject + { + ["purl"] = component.Purl, + ["version"] = component.Version ?? string.Empty, + ["scope"] = string.Empty, + ["license_spdx"] = string.Empty, + ["usage"] = string.Empty + }; + + if (component.Scopes?.Any() == true) + { + var scopesArray = new JsonArray(); + foreach (var scope in component.Scopes.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal)) + { + scopesArray.Add(scope); + } + + attributes["scopes"] = scopesArray; + } + + var provenance = ResolveProvenance(snapshot, provenanceOverride); + + var node = GraphDocumentFactory.CreateNode(new GraphNodeSpec( + Tenant: snapshot.Tenant, + Kind: ComponentKind, + CanonicalKey: new Dictionary + { + ["tenant"] = snapshot.Tenant, + ["purl"] = component.Purl, + ["source_type"] = DefaultSourceType + }, + Attributes: attributes, + Provenance: provenance, + ValidFrom: provenance.CollectedAt, + ValidTo: null)); + + cache[key] = node; + return node; + } + + private JsonObject CreateRelationshipEdge( + GraphInspectorSnapshot snapshot, + JsonObject sourceNode, + JsonObject targetNode, + GraphInspectorRelationship relationship) + { + var kind = relationship.Type?.Trim().ToLowerInvariant() switch + { + "contains" => ContainsEdgeKind, + "depends_on" => DependsOnEdgeKind, + "provides" => ProvidesEdgeKind, + "runtime_observed" => ObservedRuntimeEdgeKind, + _ => DependsOnEdgeKind + }; + + var attributes = new JsonObject + { + ["scope"] = relationship.Scope ?? string.Empty, + ["evidence_digest"] = relationship.EvidenceHash, + ["source"] = relationship.Source ?? string.Empty + }; + + var provenance = ResolveProvenance(snapshot, relationship.Provenance); + + return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec( + Tenant: snapshot.Tenant, + Kind: kind, + CanonicalKey: new Dictionary + { + ["tenant"] = snapshot.Tenant, + ["source_node_id"] = sourceNode["id"]!.GetValue(), + ["target_node_id"] = targetNode["id"]!.GetValue(), + ["sbom_digest"] = snapshot.SbomDigest + }, + Attributes: attributes, + Provenance: provenance, + ValidFrom: provenance.CollectedAt, + ValidTo: null)); + } + + private JsonObject GetOrCreateAdvisoryNode( + GraphInspectorSnapshot snapshot, + IDictionary<(string, string, string, string), JsonObject> cache, + GraphInspectorAdvisoryObservation advisory) + { + var contentHash = advisory.EvidenceHash ?? string.Empty; + var key = (snapshot.Tenant, advisory.Source, advisory.AdvisoryId, contentHash); + if (cache.TryGetValue(key, out var existing)) + { + return existing; + } + + var attributes = new JsonObject + { + ["advisory_source"] = advisory.Source, + ["advisory_id"] = advisory.AdvisoryId, + ["severity"] = advisory.Severity ?? string.Empty, + ["published_at"] = GraphTimestamp.Format(advisory.ModifiedAt), + ["content_hash"] = contentHash, + ["linkset_digest"] = advisory.LinksetDigest ?? string.Empty + }; + + var canonicalKey = new Dictionary + { + ["tenant"] = snapshot.Tenant, + ["advisory_source"] = advisory.Source, + ["advisory_id"] = advisory.AdvisoryId, + ["content_hash"] = contentHash + }; + + var provenance = ResolveProvenance(snapshot, advisory.Provenance); + + var node = GraphDocumentFactory.CreateNode(new GraphNodeSpec( + Tenant: snapshot.Tenant, + Kind: AdvisoryKind, + CanonicalKey: canonicalKey, + Attributes: attributes, + Provenance: provenance, + ValidFrom: advisory.ModifiedAt, + ValidTo: null)); + + cache[key] = node; + return node; + } + + private JsonObject CreateAffectedByEdge( + GraphInspectorSnapshot snapshot, + JsonObject componentNode, + JsonObject advisoryNode, + GraphInspectorAdvisoryObservation advisory) + { + var attributes = new JsonObject + { + ["evidence_digest"] = advisory.EvidenceHash, + ["matched_versions"] = new JsonArray(), + ["cvss"] = advisory.Cvss is null ? null : new JsonObject + { + ["vector"] = advisory.Cvss.Vector, + ["score"] = advisory.Cvss.Score + }, + ["justification"] = advisory.Justification ?? string.Empty, + ["justification_summary"] = advisory.JustificationSummary ?? string.Empty, + ["status"] = advisory.Status + }; + + var canonicalKey = new Dictionary + { + ["tenant"] = snapshot.Tenant, + ["component_node_id"] = componentNode["id"]!.GetValue(), + ["advisory_node_id"] = advisoryNode["id"]!.GetValue(), + ["linkset_digest"] = advisory.LinksetDigest ?? string.Empty + }; + + var provenance = ResolveProvenance(snapshot, advisory.Provenance); + + return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec( + Tenant: snapshot.Tenant, + Kind: AffectedByEdgeKind, + CanonicalKey: canonicalKey, + Attributes: attributes, + Provenance: provenance, + ValidFrom: advisory.ModifiedAt, + ValidTo: null)); + } + + private JsonObject GetOrCreateVexNode( + GraphInspectorSnapshot snapshot, + IDictionary<(string Tenant, string StatementId, string Source, string EvidenceHash), JsonObject> cache, + GraphInspectorVexStatement vex) + { + var key = (snapshot.Tenant, vex.StatementId, vex.Source, vex.EvidenceHash); + if (cache.TryGetValue(key, out var existing)) + { + return existing; + } + + var attributes = new JsonObject + { + ["status"] = vex.Status, + ["statement_id"] = vex.StatementId, + ["justification"] = vex.Justification, + ["impact_statement"] = vex.ImpactStatement ?? string.Empty, + ["known_exploited"] = vex.KnownExploited ?? false, + ["content_hash"] = vex.EvidenceHash + }; + + var canonicalKey = new Dictionary + { + ["tenant"] = snapshot.Tenant, + ["vex_source"] = vex.Source, + ["statement_id"] = vex.StatementId, + ["content_hash"] = vex.EvidenceHash + }; + + var provenance = ResolveProvenance(snapshot, vex.Provenance); + + var node = GraphDocumentFactory.CreateNode(new GraphNodeSpec( + Tenant: snapshot.Tenant, + Kind: VexKind, + CanonicalKey: canonicalKey, + Attributes: attributes, + Provenance: provenance, + ValidFrom: vex.IssuedAt, + ValidTo: vex.ExpiresAt)); + + cache[key] = node; + return node; + } + + private JsonObject CreateVexExemptsEdge( + GraphInspectorSnapshot snapshot, + JsonObject componentNode, + JsonObject vexNode, + GraphInspectorVexStatement vex) + { + var attributes = new JsonObject + { + ["status"] = vex.Status, + ["justification"] = vex.Justification, + ["impact_statement"] = vex.ImpactStatement ?? string.Empty, + ["evidence_digest"] = vex.EvidenceHash + }; + + var canonicalKey = new Dictionary + { + ["tenant"] = snapshot.Tenant, + ["component_node_id"] = componentNode["id"]!.GetValue(), + ["vex_node_id"] = vexNode["id"]!.GetValue(), + ["statement_hash"] = vex.EvidenceHash + }; + + var provenance = ResolveProvenance(snapshot, vex.Provenance); + + return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec( + Tenant: snapshot.Tenant, + Kind: VexExemptsEdgeKind, + CanonicalKey: canonicalKey, + Attributes: attributes, + Provenance: provenance, + ValidFrom: vex.IssuedAt, + ValidTo: vex.ExpiresAt)); + } + + private static GraphProvenanceSpec ResolveProvenance(GraphInspectorSnapshot snapshot, GraphInspectorProvenance? overrideProvenance) + { + if (overrideProvenance is not null) + { + return new GraphProvenanceSpec( + Source: string.IsNullOrWhiteSpace(overrideProvenance.Source) ? DefaultSource : overrideProvenance.Source, + CollectedAt: overrideProvenance.CollectedAt == DateTimeOffset.UnixEpoch ? snapshot.CollectedAt : overrideProvenance.CollectedAt, + SbomDigest: snapshot.SbomDigest, + EventOffset: overrideProvenance.EventOffset == 0 ? snapshot.EventOffset : overrideProvenance.EventOffset); + } + + return new GraphProvenanceSpec( + Source: DefaultSource, + CollectedAt: snapshot.CollectedAt, + SbomDigest: snapshot.SbomDigest, + EventOffset: snapshot.EventOffset); + } + + private static GraphProvenanceSpec CreateProvenance(string? source, DateTimeOffset collectedAt, string? sbomDigest, long? eventOffset) + { + return new GraphProvenanceSpec( + Source: string.IsNullOrWhiteSpace(source) ? DefaultSource : source!, + CollectedAt: collectedAt, + SbomDigest: sbomDigest, + EventOffset: eventOffset); + } + + private sealed class JsonNodeEqualityComparer : IEqualityComparer + { + public static readonly JsonNodeEqualityComparer Instance = new(); + + public bool Equals(JsonNode? x, JsonNode? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return x.ToJsonString() == y.ToJsonString(); + } + + public int GetHashCode(JsonNode obj) => obj.ToJsonString().GetHashCode(StringComparison.Ordinal); + } +} diff --git a/src/Graph/__Tests/StellaOps.Graph.Indexer.Tests/GraphInspectorTransformerTests.cs b/src/Graph/__Tests/StellaOps.Graph.Indexer.Tests/GraphInspectorTransformerTests.cs new file mode 100644 index 000000000..b49d58f48 --- /dev/null +++ b/src/Graph/__Tests/StellaOps.Graph.Indexer.Tests/GraphInspectorTransformerTests.cs @@ -0,0 +1,115 @@ +using StellaOps.Graph.Indexer.Ingestion.Inspector; +using StellaOps.Graph.Indexer.Schema; + +namespace StellaOps.Graph.Indexer.Tests; + +public sealed class GraphInspectorTransformerTests +{ + [Fact] + public void Transform_BuildsNodesAndEdges_FromInspectorSnapshot() + { + var snapshot = new GraphInspectorSnapshot + { + Tenant = "acme-dev", + ArtifactDigest = "sha256:artifact", + SbomDigest = "sha256:sbom", + CollectedAt = DateTimeOffset.Parse("2025-12-04T15:30:00Z"), + EventOffset = 5123, + Components = new[] + { + new GraphInspectorComponent + { + Purl = "pkg:maven/org.example/foo@1.2.3", + Scopes = new[] {"runtime"}, + Relationships = new[] + { + new GraphInspectorRelationship + { + Type = "depends_on", + TargetPurl = "pkg:npm/lodash@4.17.21", + Scope = "runtime", + EvidenceHash = "e1", + Source = "concelier.linkset.v1", + Provenance = new GraphInspectorProvenance + { + Source = "concelier.linkset.v1", + CollectedAt = DateTimeOffset.Parse("2025-12-04T15:29:00Z"), + EventOffset = 6000, + EvidenceHash = "e1" + } + } + }, + Advisories = new[] + { + new GraphInspectorAdvisoryObservation + { + AdvisoryId = "CVE-2024-1111", + Source = "ghsa", + Status = "affected", + Severity = "HIGH", + EvidenceHash = "a1", + LinksetDigest = "abcdef", + ModifiedAt = DateTimeOffset.Parse("2025-11-30T12:00:00Z"), + Provenance = new GraphInspectorProvenance + { + Source = "concelier.linkset.v1", + CollectedAt = DateTimeOffset.Parse("2025-11-30T11:55:00Z"), + EventOffset = 4421, + EvidenceHash = "a1" + } + } + }, + VexStatements = new[] + { + new GraphInspectorVexStatement + { + StatementId = "VEX-2025-0001", + Source = "excitor.vex.v1", + Status = "not_affected", + Justification = "component_not_present", + EvidenceHash = "v1", + IssuedAt = DateTimeOffset.Parse("2025-12-01T08:00:00Z"), + ExpiresAt = DateTimeOffset.Parse("2026-12-01T00:00:00Z"), + Provenance = new GraphInspectorProvenance + { + Source = "excitor.overlay.v1", + CollectedAt = DateTimeOffset.Parse("2025-12-01T08:00:00Z"), + EventOffset = 171, + EvidenceHash = "v1" + } + } + }, + Provenance = new GraphInspectorProvenance + { + Source = "concelier.linkset.v1", + CollectedAt = DateTimeOffset.Parse("2025-12-04T15:29:00Z"), + EventOffset = 5123, + EvidenceHash = "c1" + } + } + } + }; + + var transformer = new GraphInspectorTransformer(); + + var batch = transformer.Transform(snapshot); + + // Nodes: artifact + source component + target component + advisory + vex + Assert.Equal(5, batch.Nodes.Length); + Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue() == "artifact"); + Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue() == "component" && n["canonical_key"]!["purl"]!.GetValue() == "pkg:maven/org.example/foo@1.2.3"); + Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue() == "component" && n["canonical_key"]!["purl"]!.GetValue() == "pkg:npm/lodash@4.17.21"); + Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue() == "advisory"); + Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue() == "vex_statement"); + + // Edges: depends_on, affected_by, vex_exempts + Assert.Contains(batch.Edges, e => e["kind"]!.GetValue() == "DEPENDS_ON"); + Assert.Contains(batch.Edges, e => e["kind"]!.GetValue() == "AFFECTED_BY"); + Assert.Contains(batch.Edges, e => e["kind"]!.GetValue() == "VEX_EXEMPTS"); + + // Provenance should carry sbom digest and event offset from snapshot/provenance overrides. + var dependsOn = batch.Edges.Single(e => e["kind"]!.GetValue() == "DEPENDS_ON"); + Assert.Equal("sha256:sbom", dependsOn["provenance"]!["sbom_digest"]!.GetValue()); + Assert.Equal(6000, dependsOn["provenance"]!["event_offset"]!.GetValue()); + } +} diff --git a/src/Notifier/StellaOps.Notifier/TASKS.md b/src/Notifier/StellaOps.Notifier/TASKS.md index 57bfa576b..65e8f275e 100644 --- a/src/Notifier/StellaOps.Notifier/TASKS.md +++ b/src/Notifier/StellaOps.Notifier/TASKS.md @@ -13,4 +13,4 @@ | NOTIFY-RISK-66-001 | DONE (2025-11-24) | Notifications Service Guild · Risk Engine Guild | Added risk-events endpoint + templates/rules for severity change notifications. | | NOTIFY-RISK-67-001 | DONE (2025-11-24) | Notifications Service Guild · Policy Guild | Added routing/templates for risk profile publish/deprecate/threshold change. | | NOTIFY-RISK-68-001 | DONE (2025-11-24) | Notifications Service Guild | Default routing seeds with throttles/locales for risk alerts. | -| NOTIFY-GAPS-171-014 | TODO | Notifications Service Guild | NR1–NR10 scoped (`docs/product-advisories/31-Nov-2025 FINDINGS.md`, `docs/notifications/gaps-nr1-nr10.md`); implement schema catalog + DSSE, quotas/backpressure, retries/idempotency, security, redaction, observability, offline kit, and simulation evidence. | +| NOTIFY-GAPS-171-014 | DOING (2025-12-04) | Notifications Service Guild | NR1–NR10 scoped; schemas/catalog/fixtures/offline kit scaffolded; fill BLAKE3 digests, DSSE signatures, and tests next. | diff --git a/src/Web/StellaOps.Web/.storybook/main.ts b/src/Web/StellaOps.Web/.storybook/main.ts new file mode 100644 index 000000000..0fcde75a8 --- /dev/null +++ b/src/Web/StellaOps.Web/.storybook/main.ts @@ -0,0 +1,19 @@ +import type { StorybookConfig } from '@storybook/angular'; + +const config: StorybookConfig = { + stories: ['../src/stories/**/*.stories.@(ts|mdx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-a11y', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/angular', + options: {}, + }, + docs: { + autodocs: 'tag', + }, +}; + +export default config; diff --git a/src/Web/StellaOps.Web/.storybook/preview.ts b/src/Web/StellaOps.Web/.storybook/preview.ts new file mode 100644 index 000000000..0b9a880a0 --- /dev/null +++ b/src/Web/StellaOps.Web/.storybook/preview.ts @@ -0,0 +1,53 @@ +import type { Preview } from '@storybook/angular'; +import '../src/styles.scss'; + +export const globalTypes = { + reduceMotion: { + name: 'Reduced Motion', + description: 'Toggle reduced-motion mode for motion tokens', + defaultValue: false, + toolbar: { + icon: 'contrast', + items: [ + { value: false, title: 'Motion enabled' }, + { value: true, title: 'Reduce motion' }, + ], + }, + }, +}; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + // Keep enabled; violations surface in the Storybook panel + disable: false, + }, + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#f6f8fb' }, + { name: 'dark', value: '#0f172a' }, + ], + }, + }, + decorators: [ + (story, context) => { + const root = document.documentElement; + if (context.globals.reduceMotion) { + root.dataset.reduceMotion = '1'; + } else { + root.dataset.reduceMotion = '0'; + } + return story(); + }, + ], +}; + +export default preview; diff --git a/src/Web/StellaOps.Web/.storybook/tsconfig.json b/src/Web/StellaOps.Web/.storybook/tsconfig.json new file mode 100644 index 000000000..29f27f186 --- /dev/null +++ b/src/Web/StellaOps.Web/.storybook/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["storybook__angular", "node"] + }, + "include": [ + "../src/**/*.ts", + "../.storybook/**/*.ts", + "../src/**/*.stories.ts" + ] +} diff --git a/src/Web/StellaOps.Web/TASKS.md b/src/Web/StellaOps.Web/TASKS.md index 741c48f09..e4cb0b54b 100644 --- a/src/Web/StellaOps.Web/TASKS.md +++ b/src/Web/StellaOps.Web/TASKS.md @@ -10,3 +10,4 @@ | WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). | | WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). | | WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). | +| UI-MICRO-GAPS-0209-011 | DOING (2025-12-04) | Motion token catalog + Storybook/Playwright a11y harness added; remaining work: component mapping, perf budgets, deterministic snapshots. | diff --git a/src/Web/StellaOps.Web/package.json b/src/Web/StellaOps.Web/package.json index 2bea2a78d..6b09e3233 100644 --- a/src/Web/StellaOps.Web/package.json +++ b/src/Web/StellaOps.Web/package.json @@ -12,7 +12,10 @@ "test:e2e": "playwright test", "serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1", "verify:chromium": "node ./scripts/verify-chromium.js", - "ci:install": "npm ci --prefer-offline --no-audit --no-fund" + "ci:install": "npm ci --prefer-offline --no-audit --no-fund", + "storybook": "storybook dev -p 4600", + "storybook:build": "storybook build", + "test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts" }, "engines": { "node": ">=20.11.0", @@ -36,7 +39,16 @@ "@angular-devkit/build-angular": "^17.3.17", "@angular/cli": "^17.3.17", "@angular/compiler-cli": "^17.3.0", + "@axe-core/playwright": "4.8.4", "@playwright/test": "^1.47.2", + "@storybook/addon-a11y": "8.1.0", + "@storybook/addon-essentials": "8.1.0", + "@storybook/addon-interactions": "8.1.0", + "@storybook/angular": "8.1.0", + "@storybook/angular-renderer": "8.1.0", + "@storybook/test": "8.1.0", + "@storybook/testing-library": "0.2.2", + "storybook": "8.1.0", "@types/jasmine": "~5.1.0", "jasmine-core": "~5.1.0", "karma": "~6.4.0", diff --git a/src/Web/StellaOps.Web/src/app/styles/motion-tokens.ts b/src/Web/StellaOps.Web/src/app/styles/motion-tokens.ts new file mode 100644 index 000000000..689ce1377 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/styles/motion-tokens.ts @@ -0,0 +1,39 @@ +export type MotionToken = + | 'durationXs' + | 'durationSm' + | 'durationMd' + | 'durationLg' + | 'durationXl' + | 'easeStandard' + | 'easeEntrance' + | 'easeExit' + | 'easeBounce' + | 'translateSm' + | 'translateMd' + | 'translateLg' + | 'scaleSm' + | 'scaleMd'; + +export const motionTokens: Record = { + durationXs: 'var(--motion-duration-xs)', + durationSm: 'var(--motion-duration-sm)', + durationMd: 'var(--motion-duration-md)', + durationLg: 'var(--motion-duration-lg)', + durationXl: 'var(--motion-duration-xl)', + easeStandard: 'var(--motion-ease-standard)', + easeEntrance: 'var(--motion-ease-entrance)', + easeExit: 'var(--motion-ease-exit)', + easeBounce: 'var(--motion-ease-bounce)', + translateSm: 'var(--motion-translate-sm)', + translateMd: 'var(--motion-translate-md)', + translateLg: 'var(--motion-translate-lg)', + scaleSm: 'var(--motion-scale-sm)', + scaleMd: 'var(--motion-scale-md)', +}; + +export function reduceMotionEnabled(): boolean { + return ( + document.documentElement.dataset.reduceMotion === '1' || + document.documentElement.dataset.reduceMotion === 'true' + ); +} diff --git a/src/Web/StellaOps.Web/src/stories/motion-tokens.stories.ts b/src/Web/StellaOps.Web/src/stories/motion-tokens.stories.ts new file mode 100644 index 000000000..05d69a952 --- /dev/null +++ b/src/Web/StellaOps.Web/src/stories/motion-tokens.stories.ts @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +type CardArgs = { + title: string; + description: string; + className: string; +}; + +const meta: Meta = { + title: 'Design Tokens/Motion', + args: { + title: 'Motion tokens', + description: 'Durations, easing, and transforms with reduced-motion fallback.', + className: 'motion-fade-in', + }, + parameters: { + a11y: { + element: '#motion-token-preview', + }, + }, + render: ({ title, description, className }) => ({ + template: ` +
+
+

{{title}}

+

{{description}}

+
+ duration-md: var(--motion-duration-md) + ease: var(--motion-ease-standard) + translate: var(--motion-translate-md) + scale: var(--motion-scale-sm) +
+ +
+
+

Reduced motion

+

Toggle "Reduced Motion" in toolbar to verify zero-duration paths.

+
+
+ `, + styles: [ + ` + :host { + display: block; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #0f172a; + } + .card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 16px; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + } + h3, h4 { + margin: 0 0 4px 0; + } + p { + margin: 0 0 12px 0; + color: #475569; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + } + .chip { + padding: 6px 10px; + border-radius: 999px; + background: #edf2ff; + color: #1e3a8a; + font-size: 12px; + font-weight: 600; + } + .cta { + background: #0f172a; + color: #fff; + border: none; + border-radius: 8px; + padding: 10px 14px; + font-weight: 700; + cursor: pointer; + transition: transform var(--motion-duration-sm) var(--motion-ease-standard), + box-shadow var(--motion-duration-sm) var(--motion-ease-standard); + } + .cta:hover { + transform: translateY(calc(-1 * var(--motion-translate-sm))); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16); + } + [data-reduce-motion='1'] .cta, + [data-reduce-motion='true'] .cta { + transform: none !important; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + } + `, + ], + }), +}; + +export default meta; + +export const Tokens: StoryObj = {}; diff --git a/src/Web/StellaOps.Web/src/styles.scss b/src/Web/StellaOps.Web/src/styles.scss index 9907dc13b..a647b7883 100644 --- a/src/Web/StellaOps.Web/src/styles.scss +++ b/src/Web/StellaOps.Web/src/styles.scss @@ -1 +1,52 @@ -/* You can add global styles to this file, and also import other style files */ +@import './styles/tokens/motion'; + +/* Global motion helpers */ +.motion-fade-in { + animation: fade-in var(--motion-duration-md) var(--motion-ease-standard); +} + +.motion-slide-up { + animation: slide-up var(--motion-duration-lg) var(--motion-ease-entrance); +} + +.motion-scale-pop { + animation: scale-pop var(--motion-duration-sm) var(--motion-ease-bounce); +} + +@keyframes fade-in { + from { + opacity: var(--motion-opacity-hidden); + } + to { + opacity: var(--motion-opacity-visible); + } +} + +@keyframes slide-up { + from { + transform: translateY(var(--motion-translate-lg)); + opacity: var(--motion-opacity-muted); + } + to { + transform: translateY(0); + opacity: var(--motion-opacity-visible); + } +} + +@keyframes scale-pop { + from { + transform: scale(var(--motion-scale-md)); + opacity: var(--motion-opacity-muted); + } + to { + transform: scale(1); + opacity: var(--motion-opacity-visible); + } +} + +[data-reduce-motion='1'] *, +[data-reduce-motion='true'] * { + animation-duration: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0ms !important; +} diff --git a/src/Web/StellaOps.Web/src/styles/tokens/_motion.scss b/src/Web/StellaOps.Web/src/styles/tokens/_motion.scss new file mode 100644 index 000000000..0e2464d3b --- /dev/null +++ b/src/Web/StellaOps.Web/src/styles/tokens/_motion.scss @@ -0,0 +1,54 @@ +:root { + /* Durations */ + --motion-duration-xs: 80ms; + --motion-duration-sm: 140ms; + --motion-duration-md: 200ms; + --motion-duration-lg: 260ms; + --motion-duration-xl: 320ms; + + /* Easing */ + --motion-ease-standard: cubic-bezier(0.2, 0, 0, 1); + --motion-ease-entrance: cubic-bezier(0.18, 0.89, 0.32, 1); + --motion-ease-exit: cubic-bezier(0.36, 0, 0.66, -0.56); + --motion-ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Distances / transforms */ + --motion-translate-sm: 4px; + --motion-translate-md: 8px; + --motion-translate-lg: 16px; + --motion-scale-sm: 0.98; + --motion-scale-md: 0.96; + + /* Opacity steps */ + --motion-opacity-hidden: 0; + --motion-opacity-muted: 0.6; + --motion-opacity-visible: 1; +} + +[data-reduce-motion='1'], +[data-reduce-motion='true'] { + --motion-duration-xs: 0ms; + --motion-duration-sm: 0ms; + --motion-duration-md: 0ms; + --motion-duration-lg: 0ms; + --motion-duration-xl: 0ms; + --motion-ease-standard: linear; + --motion-ease-entrance: linear; + --motion-ease-exit: linear; + --motion-ease-bounce: linear; + --motion-translate-sm: 0px; + --motion-translate-md: 0px; + --motion-translate-lg: 0px; + --motion-scale-sm: 1; + --motion-scale-md: 1; +} + +@mixin reduce-motion-friendly { + @media (prefers-reduced-motion: reduce) { + @content; + } + [data-reduce-motion='1'] &, + [data-reduce-motion='true'] & { + @content; + } +} diff --git a/src/Web/StellaOps.Web/tests/e2e/a11y-smoke.spec.ts b/src/Web/StellaOps.Web/tests/e2e/a11y-smoke.spec.ts new file mode 100644 index 000000000..e6d970af2 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/a11y-smoke.spec.ts @@ -0,0 +1,44 @@ +import { test, expect, Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import fs from 'node:fs'; +import path from 'node:path'; + +const shouldFail = process.env.FAIL_ON_A11Y === '1'; +const reportDir = path.join(process.cwd(), 'test-results'); + +async function writeReport(filename: string, data: unknown) { + fs.mkdirSync(reportDir, { recursive: true }); + fs.writeFileSync(path.join(reportDir, filename), JSON.stringify(data, null, 2)); +} + +async function runA11y(url: string, page: Page) { + await page.goto(url); + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id)); + await writeReport( + `a11y-${url.replace(/\\W+/g, '_') || 'home'}.json`, + { url: page.url(), violations } + ); + if (shouldFail) { + expect(violations).toEqual([]); + } + return violations; +} + +test.describe('a11y-smoke', () => { + test('home page baseline', async ({ page }, testInfo) => { + const violations = await runA11y('/', page); + testInfo.annotations.push({ + type: 'a11y', + description: `${violations.length} violations (set FAIL_ON_A11Y=1 to fail on any)`, + }); + }); + + test('graph explorer shell', async ({ page }, testInfo) => { + const violations = await runA11y('/graph', page); + testInfo.annotations.push({ + type: 'a11y', + description: `${violations.length} violations (/graph)`, + }); + }); +}); diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/bundle.json b/tests/EvidenceLocker/Bundles/Golden/portable/bundle.json new file mode 100644 index 000000000..e74e4e850 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/bundle.json @@ -0,0 +1,7 @@ +{ + "bundleId": "11111111111111111111111111111111", + "tenant": "redacted", + "kind": "evaluation", + "createdAt": "2025-12-04T00:00:00Z", + "portable": true +} diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/checksums.txt b/tests/EvidenceLocker/Bundles/Golden/portable/checksums.txt new file mode 100644 index 000000000..b098f563a --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/checksums.txt @@ -0,0 +1,14 @@ +{ + "algorithm": "sha256", + "root": "72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf", + "generatedAt": "2025-12-04T00:00:00Z", + "bundleId": "11111111111111111111111111111111", + "tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "entries": [ + { "canonicalPath": "bundle.json", "sha256": "10695174db1b549d77be583e529a249713e9bd23e46cc5e73250db5dfc92c4a9", "sizeBytes": 160 }, + { "canonicalPath": "instructions-portable.txt", "sha256": "dd2a3b62857cf331b423e7dc3b869ad2dc9bfa852109a20bcbecc7bcef9bdcb7", "sizeBytes": 180 }, + { "canonicalPath": "linksets.ndjson", "sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d", "sizeBytes": 151 }, + { "canonicalPath": "observations.ndjson", "sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd", "sizeBytes": 149 } + ], + "chunking": { "strategy": "none" } +} diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/expected.json b/tests/EvidenceLocker/Bundles/Golden/portable/expected.json new file mode 100644 index 000000000..753cfca9f --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/expected.json @@ -0,0 +1,18 @@ +{ + "bundleId": "11111111111111111111111111111111", + "tenantRedacted": true, + "merkleRoot": "72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf", + "subject": "sha256:72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf", + "entries": [ + "bundle.json", + "instructions-portable.txt", + "linksets.ndjson", + "observations.ndjson" + ], + "dsseKeyId": "demo-ed25519", + "logPolicy": "skip-offline", + "redaction": { + "maskedFields": ["tenantId"], + "tenantToken": "portable-tenant-01" + } +} diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/instructions-portable.txt b/tests/EvidenceLocker/Bundles/Golden/portable/instructions-portable.txt new file mode 100644 index 000000000..c6ed50dd6 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/instructions-portable.txt @@ -0,0 +1,4 @@ +Portable bundle verification: +1) sha256sum -c checksums.txt +2) expect no tenant identifiers in manifest or bundle.json +3) merkle_root=$(sha256sum checksums.txt | awk '{print $1}') diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/linksets.ndjson b/tests/EvidenceLocker/Bundles/Golden/portable/linksets.ndjson new file mode 100644 index 000000000..06c550565 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/linksets.ndjson @@ -0,0 +1 @@ +{"linksetId":"lnk-demo-001","advisoryId":"CVE-2025-0001","components":["pkg:deb/openssl@1.1.1w"],"normalized":true,"createdAt":"2025-11-30T00:05:00Z"} diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/manifest.json b/tests/EvidenceLocker/Bundles/Golden/portable/manifest.json new file mode 100644 index 000000000..cabf99e99 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/manifest.json @@ -0,0 +1,58 @@ +{ + "bundleId": "11111111111111111111111111111111", + "tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": "evaluation", + "createdAt": "2025-12-04T00:00:00Z", + "metadata": { + "scope": "demo", + "portable": "true" + }, + "redaction": { + "portable": true, + "maskedFields": ["tenantId"], + "tenantToken": "portable-tenant-01" + }, + "entries": [ + { + "section": "manifest", + "canonicalPath": "bundle.json", + "sha256": "10695174db1b549d77be583e529a249713e9bd23e46cc5e73250db5dfc92c4a9", + "sizeBytes": 160, + "mediaType": "application/json", + "attributes": { + "role": "bundle", + "portable": "true" + } + }, + { + "section": "evidence", + "canonicalPath": "observations.ndjson", + "sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd", + "sizeBytes": 149, + "mediaType": "application/x-ndjson", + "attributes": { + "dataset": "observations" + } + }, + { + "section": "evidence", + "canonicalPath": "linksets.ndjson", + "sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d", + "sizeBytes": 151, + "mediaType": "application/x-ndjson", + "attributes": { + "dataset": "linksets" + } + }, + { + "section": "docs", + "canonicalPath": "instructions-portable.txt", + "sha256": "dd2a3b62857cf331b423e7dc3b869ad2dc9bfa852109a20bcbecc7bcef9bdcb7", + "sizeBytes": 180, + "mediaType": "text/plain", + "attributes": { + "purpose": "verification" + } + } + ] +} diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/observations.ndjson b/tests/EvidenceLocker/Bundles/Golden/portable/observations.ndjson new file mode 100644 index 000000000..f16dae047 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/observations.ndjson @@ -0,0 +1 @@ +{"observationId":"obs-demo-001","advisoryId":"CVE-2025-0001","component":"pkg:deb/openssl@1.1.1w","source":"nvd","fetchedAt":"2025-11-30T00:00:00Z"} diff --git a/tests/EvidenceLocker/Bundles/Golden/portable/signature.json b/tests/EvidenceLocker/Bundles/Golden/portable/signature.json new file mode 100644 index 000000000..9c49339ee --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/portable/signature.json @@ -0,0 +1,15 @@ +{ + "payloadType": "application/vnd.stellaops.evidence+json", + "payload": "ewogICJidW5kbGVJZCI6ICIxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsCiAgInRlbmFudElkIjogImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwKICAia2luZCI6ICJldmFsdWF0aW9uIiwKICAiY3JlYXRlZEF0IjogIjIwMjUtMTItMDRUMDA6MDA6MDBaIiwKICAibWV0YWRhdGEiOiB7CiAgICAic2NvcGUiOiAiZGVtbyIsCiAgICAicG9ydGFibGUiOiAidHJ1ZSIKICB9LAogICJyZWRhY3Rpb24iOiB7CiAgICAicG9ydGFibGUiOiB0cnVlLAogICAgIm1hc2tlZEZpZWxkcyI6IFsidGVuYW50SWQiXSwKICAgICJ0ZW5hbnRUb2tlbiI6ICJwb3J0YWJsZS10ZW5hbnQtMDEiCiAgfSwKICAiZW50cmllcyI6IFsKICAgIHsKICAgICAgInNlY3Rpb24iOiAibWFuaWZlc3QiLAogICAgICAiY2Fub25pY2FsUGF0aCI6ICJidW5kbGUuanNvbiIsCiAgICAgICJzaGEyNTYiOiAiMTA2OTUxNzRkYjFiNTQ5ZDc3YmU1ODNlNTI5YTI0OTcxM2U5YmQyM2U0NmNjNWU3MzI1MGRiNWRmYzkyYzRhOSIsCiAgICAgICJzaXplQnl0ZXMiOiAxNjAsCiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vanNvbiIsCiAgICAgICJhdHRyaWJ1dGVzIjogewogICAgICAgICJyb2xlIjogImJ1bmRsZSIsCiAgICAgICAgInBvcnRhYmxlIjogInRydWUiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJzZWN0aW9uIjogImV2aWRlbmNlIiwKICAgICAgImNhbm9uaWNhbFBhdGgiOiAib2JzZXJ2YXRpb25zLm5kanNvbiIsCiAgICAgICJzaGEyNTYiOiAiYzUyM2Y4MmU3MWM4YTFiZDliZTA2NTA4ODNmYWYwMGVjMzlhNzkyMDIzMDY2MTA1ZDdjZGE1NDRhZDZlZjVmZCIsCiAgICAgICJzaXplQnl0ZXMiOiAxNDksCiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24veC1uZGpzb24iLAogICAgICAiYXR0cmlidXRlcyI6IHsKICAgICAgICAiZGF0YXNldCI6ICJvYnNlcnZhdGlvbnMiCiAgICAgIH0KICAgIH0sCiAgICB7CiAgICAgICJzZWN0aW9uIjogImV2aWRlbmNlIiwKICAgICAgImNhbm9uaWNhbFBhdGgiOiAibGlua3NldHMubmRqc29uIiwKICAgICAgInNoYTI1NiI6ICJhNGQ4NGJiYzMyNjIxOTBmZDNlMWY1ZGJjMTU5MTVjOTdlNDY0MzI2YTU2NTM0NDgzY2U4MTBjOTA1Mjg4YjlkIiwKICAgICAgInNpemVCeXRlcyI6IDE1MSwKICAgICAgIm1lZGlhVHlwZSI6ICJhcHBsaWNhdGlvbi94LW5kanNvbiIsCiAgICAgICJhdHRyaWJ1dGVzIjogewogICAgICAgICJkYXRhc2V0IjogImxpbmtzZXRzIgogICAgICB9CiAgICB9LAogICAgewogICAgICAic2VjdGlvbiI6ICJkb2NzIiwKICAgICAgImNhbm9uaWNhbFBhdGgiOiAiaW5zdHJ1Y3Rpb25zLXBvcnRhYmxlLnR4dCIsCiAgICAgICJzaGEyNTYiOiAiZGQyYTNiNjI4NTdjZjMzMWI0MjNlN2RjM2I4NjlhZDJkYzliZmE4NTIxMDlhMjBiY2JlY2M3YmNlZjliZGNiNyIsCiAgICAgICJzaXplQnl0ZXMiOiAxODAsCiAgICAgICJtZWRpYVR5cGUiOiAidGV4dC9wbGFpbiIsCiAgICAgICJhdHRyaWJ1dGVzIjogewogICAgICAgICJwdXJwb3NlIjogInZlcmlmaWNhdGlvbiIKICAgICAgfQogICAgfQogIF0KfQo=", + "signatures": [ + { + "keyid": "demo-ed25519", + "sig": "MEQCIGZkZGVtb3NpZw==", + "algorithm": "ed25519", + "provider": "sovereign-default", + "subjectMerkleRoot": "72c82a7a3d114164d491e2ecd7098bc015b115ee1ec7c42d648f0348e573cfcf", + "transparency": null, + "log_policy": "skip-offline" + } + ] +} diff --git a/tests/EvidenceLocker/Bundles/Golden/replay/expected.json b/tests/EvidenceLocker/Bundles/Golden/replay/expected.json new file mode 100644 index 000000000..78460033f --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/replay/expected.json @@ -0,0 +1,7 @@ +{ + "recordDigest": "sha256:8765b4a8411e76b36a2d2d43eba4c2197b4dcf0c5c0a11685ce46780a7c54222", + "sequence": 0, + "ledgerUri": "offline://demo-ledger", + "dsseEnvelope": "ZHNzZV9lbmNfZGVtbyIs", + "ordering": "recordedAtUtc, scanId" +} diff --git a/tests/EvidenceLocker/Bundles/Golden/replay/replay.ndjson b/tests/EvidenceLocker/Bundles/Golden/replay/replay.ndjson new file mode 100644 index 000000000..b912e30ad --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/replay/replay.ndjson @@ -0,0 +1 @@ +{"scanId":"22222222-2222-4222-8222-222222222222","tenantId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","subjectDigest":"sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596","scanKind":"sbom","startedAtUtc":"2025-12-03T00:00:00Z","completedAtUtc":"2025-12-03T00:10:00Z","recordedAtUtc":"2025-12-03T00:10:01Z","artifacts":[{"type":"sbom","digest":"sha256:aaaa","uri":"s3://demo/sbom"}],"provenance":{"dsseEnvelope":"ZHNzZV9lbmNfZGVtbyIs"},"summary":{"findings":1,"advisories":1,"policies":0}} diff --git a/tests/EvidenceLocker/Bundles/Golden/replay/replay.sha256 b/tests/EvidenceLocker/Bundles/Golden/replay/replay.sha256 new file mode 100644 index 000000000..c044250c1 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/replay/replay.sha256 @@ -0,0 +1 @@ +8765b4a8411e76b36a2d2d43eba4c2197b4dcf0c5c0a11685ce46780a7c54222 replay.ndjson diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/bundle.json b/tests/EvidenceLocker/Bundles/Golden/sealed/bundle.json new file mode 100644 index 000000000..546e380f8 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/bundle.json @@ -0,0 +1,7 @@ +{ + "bundleId": "11111111111111111111111111111111", + "tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": "evaluation", + "createdAt": "2025-12-04T00:00:00Z", + "portable": false +} diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/checksums.txt b/tests/EvidenceLocker/Bundles/Golden/sealed/checksums.txt new file mode 100644 index 000000000..13bbe0f47 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/checksums.txt @@ -0,0 +1,14 @@ +{ + "algorithm": "sha256", + "root": "c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596", + "generatedAt": "2025-12-04T00:00:00Z", + "bundleId": "11111111111111111111111111111111", + "tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "entries": [ + { "canonicalPath": "bundle.json", "sha256": "86872809b585f9b43f53b12a8fb27dbb0a3b9c4f74e41c38118877ebcff1c273", "sizeBytes": 187 }, + { "canonicalPath": "instructions.txt", "sha256": "39a5880af850121919a540dd4528e49a3b5687cb922195b07db2c56f9e90dd1b", "sizeBytes": 160 }, + { "canonicalPath": "linksets.ndjson", "sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d", "sizeBytes": 151 }, + { "canonicalPath": "observations.ndjson", "sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd", "sizeBytes": 149 } + ], + "chunking": { "strategy": "none" } +} diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/expected.json b/tests/EvidenceLocker/Bundles/Golden/sealed/expected.json new file mode 100644 index 000000000..c003803b4 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/expected.json @@ -0,0 +1,14 @@ +{ + "bundleId": "11111111111111111111111111111111", + "tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "merkleRoot": "c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596", + "subject": "sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596", + "entries": [ + "bundle.json", + "instructions.txt", + "linksets.ndjson", + "observations.ndjson" + ], + "dsseKeyId": "demo-ed25519", + "logPolicy": "skip-offline" +} diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/instructions.txt b/tests/EvidenceLocker/Bundles/Golden/sealed/instructions.txt new file mode 100644 index 000000000..4130eff23 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/instructions.txt @@ -0,0 +1,4 @@ +Offline verification steps: +1) sha256sum -c checksums.txt +2) merkle_root=$(sha256sum checksums.txt | awk '{print $1}') +3) compare merkle_root with DSSE subject diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/linksets.ndjson b/tests/EvidenceLocker/Bundles/Golden/sealed/linksets.ndjson new file mode 100644 index 000000000..06c550565 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/linksets.ndjson @@ -0,0 +1 @@ +{"linksetId":"lnk-demo-001","advisoryId":"CVE-2025-0001","components":["pkg:deb/openssl@1.1.1w"],"normalized":true,"createdAt":"2025-11-30T00:05:00Z"} diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json b/tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json new file mode 100644 index 000000000..76270d5a8 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json @@ -0,0 +1,52 @@ +{ + "bundleId": "11111111111111111111111111111111", + "tenantId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": "evaluation", + "createdAt": "2025-12-04T00:00:00Z", + "metadata": { + "scope": "demo", + "advisory": "CVE-2025-0001" + }, + "entries": [ + { + "section": "manifest", + "canonicalPath": "bundle.json", + "sha256": "86872809b585f9b43f53b12a8fb27dbb0a3b9c4f74e41c38118877ebcff1c273", + "sizeBytes": 187, + "mediaType": "application/json", + "attributes": { + "role": "bundle" + } + }, + { + "section": "evidence", + "canonicalPath": "observations.ndjson", + "sha256": "c523f82e71c8a1bd9be0650883faf00ec39a792023066105d7cda544ad6ef5fd", + "sizeBytes": 149, + "mediaType": "application/x-ndjson", + "attributes": { + "dataset": "observations" + } + }, + { + "section": "evidence", + "canonicalPath": "linksets.ndjson", + "sha256": "a4d84bbc3262190fd3e1f5dbc15915c97e464326a56534483ce810c905288b9d", + "sizeBytes": 151, + "mediaType": "application/x-ndjson", + "attributes": { + "dataset": "linksets" + } + }, + { + "section": "docs", + "canonicalPath": "instructions.txt", + "sha256": "39a5880af850121919a540dd4528e49a3b5687cb922195b07db2c56f9e90dd1b", + "sizeBytes": 160, + "mediaType": "text/plain", + "attributes": { + "purpose": "verification" + } + } + ] +} diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/observations.ndjson b/tests/EvidenceLocker/Bundles/Golden/sealed/observations.ndjson new file mode 100644 index 000000000..f16dae047 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/observations.ndjson @@ -0,0 +1 @@ +{"observationId":"obs-demo-001","advisoryId":"CVE-2025-0001","component":"pkg:deb/openssl@1.1.1w","source":"nvd","fetchedAt":"2025-11-30T00:00:00Z"} diff --git a/tests/EvidenceLocker/Bundles/Golden/sealed/signature.json b/tests/EvidenceLocker/Bundles/Golden/sealed/signature.json new file mode 100644 index 000000000..5077493e7 --- /dev/null +++ b/tests/EvidenceLocker/Bundles/Golden/sealed/signature.json @@ -0,0 +1,15 @@ +{ + "payloadType": "application/vnd.stellaops.evidence+json", + "payload": "ewogICJidW5kbGVJZCI6ICIxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsCiAgInRlbmFudElkIjogImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwKICAia2luZCI6ICJldmFsdWF0aW9uIiwKICAiY3JlYXRlZEF0IjogIjIwMjUtMTItMDRUMDA6MDA6MDBaIiwKICAibWV0YWRhdGEiOiB7CiAgICAic2NvcGUiOiAiZGVtbyIsCiAgICAiYWR2aXNvcnkiOiAiQ1ZFLTIwMjUtMDAwMSIKICB9LAogICJlbnRyaWVzIjogWwogICAgewogICAgICAic2VjdGlvbiI6ICJtYW5pZmVzdCIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogImJ1bmRsZS5qc29uIiwKICAgICAgInNoYTI1NiI6ICI4Njg3MjgwOWI1ODVmOWI0M2Y1M2IxMmE4ZmIyN2RiYjBhM2I5YzRmNzRlNDFjMzgxMTg4NzdlYmNmZjFjMjczIiwKICAgICAgInNpemVCeXRlcyI6IDE4NywKICAgICAgIm1lZGlhVHlwZSI6ICJhcHBsaWNhdGlvbi9qc29uIiwKICAgICAgImF0dHJpYnV0ZXMiOiB7CiAgICAgICAgInJvbGUiOiAiYnVuZGxlIgogICAgICB9CiAgICB9LAogICAgewogICAgICAic2VjdGlvbiI6ICJldmlkZW5jZSIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogIm9ic2VydmF0aW9ucy5uZGpzb24iLAogICAgICAic2hhMjU2IjogImM1MjNmODJlNzFjOGExYmQ5YmUwNjUwODgzZmFmMDBlYzM5YTc5MjAyMzA2NjEwNWQ3Y2RhNTQ0YWQ2ZWY1ZmQiLAogICAgICAic2l6ZUJ5dGVzIjogMTQ5LAogICAgICAibWVkaWFUeXBlIjogImFwcGxpY2F0aW9uL3gtbmRqc29uIiwKICAgICAgImF0dHJpYnV0ZXMiOiB7CiAgICAgICAgImRhdGFzZXQiOiAib2JzZXJ2YXRpb25zIgogICAgICB9CiAgICB9LAogICAgewogICAgICAic2VjdGlvbiI6ICJldmlkZW5jZSIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogImxpbmtzZXRzLm5kanNvbiIsCiAgICAgICJzaGEyNTYiOiAiYTRkODRiYmMzMjYyMTkwZmQzZTFmNWRiYzE1OTE1Yzk3ZTQ2NDMyNmE1NjUzNDQ4M2NlODEwYzkwNTI4OGI5ZCIsCiAgICAgICJzaXplQnl0ZXMiOiAxNTEsCiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24veC1uZGpzb24iLAogICAgICAiYXR0cmlidXRlcyI6IHsKICAgICAgICAiZGF0YXNldCI6ICJsaW5rc2V0cyIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgInNlY3Rpb24iOiAiZG9jcyIsCiAgICAgICJjYW5vbmljYWxQYXRoIjogImluc3RydWN0aW9ucy50eHQiLAogICAgICAic2hhMjU2IjogIjM5YTU4ODBhZjg1MDEyMTkxOWE1NDBkZDQ1MjhlNDlhM2I1Njg3Y2I5MjIxOTViMDdkYjJjNTZmOWU5MGRkMWIiLAogICAgICAic2l6ZUJ5dGVzIjogMTYwLAogICAgICAibWVkaWFUeXBlIjogInRleHQvcGxhaW4iLAogICAgICAiYXR0cmlidXRlcyI6IHsKICAgICAgICAicHVycG9zZSI6ICJ2ZXJpZmljYXRpb24iCiAgICAgIH0KICAgIH0KICBdCn0K", + "signatures": [ + { + "keyid": "demo-ed25519", + "sig": "MEQCIGZkZGVtb3NpZw==", + "algorithm": "ed25519", + "provider": "sovereign-default", + "subjectMerkleRoot": "c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596", + "transparency": null, + "log_policy": "skip-offline" + } + ] +} diff --git a/tests/offline/NotifyKitDeterminismTests.sh b/tests/offline/NotifyKitDeterminismTests.sh new file mode 100644 index 000000000..71cc0d578 --- /dev/null +++ b/tests/offline/NotifyKitDeterminismTests.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +KIT="$ROOT/offline/notifier" + +if [ ! -f "$KIT/notify-kit.manifest.json" ]; then + echo "notify-kit.manifest.json missing" >&2 + exit 1 +fi + +if [ ! -f "$KIT/artifact-hashes.json" ]; then + echo "artifact-hashes.json missing" >&2 + exit 1 +fi + +echo "Notify kit files present; hash verification TODO pending BLAKE3 signer availability."