diff --git a/NuGet.config b/NuGet.config index 2b46621d9..2161a074a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,25 +1,25 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -28,9 +28,23 @@ - + + + + + + + + + + + + + + + - + @@ -38,8 +52,8 @@ - - - - - + + + + + diff --git a/docs/dev/aoc-normalization-removal-notes.md b/docs/dev/aoc-normalization-removal-notes.md index 2e0c51c55..322ac4c2b 100644 --- a/docs/dev/aoc-normalization-removal-notes.md +++ b/docs/dev/aoc-normalization-removal-notes.md @@ -22,3 +22,4 @@ Document follow-up actions for CONCELIER-CORE-AOC-19-004 as we unwind the final - 2025-11-05: Catalogued residual normalization paths tied to the legacy Merge service and outlined `noMergeEnabled` feature-toggle work to keep AOC ingestion fully merge-free. - 2025-11-05 19:20Z: Observation factory/linkset now preserve upstream ordering and duplicates; canonicalisation shifts to downstream services. - 2025-11-06: Documented post-merge rollout plan and annotated sprint trackers with analyzer gating updates. +- 2025-11-06 23:30Z: Concelier core/linkset query paths now keep alias/reference casing & whitespace intact; alias filters switched to case-insensitive regex so raw data and lookups remain compatible. diff --git a/docs/events/orchestrator-scanner-events.md b/docs/events/orchestrator-scanner-events.md index a90b890b8..4169303a4 100644 --- a/docs/events/orchestrator-scanner-events.md +++ b/docs/events/orchestrator-scanner-events.md @@ -1,39 +1,39 @@ -# Scanner Orchestrator Events (ORCH-SVC-38-101) - -Last updated: 2025-10-26 - -The Notifications Studio initiative (NOTIFY-SVC-38-001) and orchestrator backlog (ORCH-SVC-38-101) standardise how platform services emit lifecycle events. This document describes the Scanner WebService contract for the new **orchestrator envelopes** (`scanner.event.*`) and how they supersede the legacy Redis-backed `scanner.report.ready` / `scanner.scan.completed` events. - -## 1. Envelope overview - -Orchestrator events share a deterministic JSON envelope: - -| Field | Type | Notes | -|-------|------|-------| -| `eventId` | `uuid` | Globally unique identifier generated per occurrence. | -| `kind` | `string` | Event identifier; Scanner emits `scanner.event.report.ready` and `scanner.event.scan.completed`. | -| `version` | `integer` | Schema version. Initial release uses `1`. | -| `tenant` | `string` | Tenant that owns the scan/report. Mirrors Authority claims. | -| `occurredAt` | `date-time` | UTC instant when the underlying state transition happened (e.g., report persisted). | -| `recordedAt` | `date-time` | UTC instant when the event was durably written. Optional but recommended. | -| `source` | `string` | Producer identifier (`scanner.webservice`). | -| `idempotencyKey` | `string` | Deterministic key for duplicate suppression (see §4). | -| `correlationId` | `string` | Maps back to the API request or scan identifier. | -| `traceId` / `spanId` | `string` | W3C trace context propagated into downstream telemetry. | -| `scope` | `object` | Describes the affected artefact. Requires `repo` and `digest`; optional `namespace`, `component`, `image`. | -| `attributes` | `object` | Flat string map for frequently queried metadata (e.g., policy revision). | -| `payload` | `object` | Event-specific body (see §2). | - -Canonical schemas live under `docs/events/scanner.event.*@1.json`. Samples that round-trip through `NotifyCanonicalJsonSerializer` are stored in `docs/events/samples/`. - -## 2. Event kinds and payloads - -### 2.1 `scanner.event.report.ready` - -Emitted once a signed report is persisted and attested. Payload highlights: - -- `reportId` / `scanId` — identifiers for the persisted report and originating scan. Until Scan IDs are surfaced by the API, `scanId` mirrors `reportId` so downstream correlators can stabilise on a single key. -- **Attributes:** `reportId`, `policyRevisionId`, `policyDigest`, `verdict` — pre-sorted for deterministic routing. +# Scanner Orchestrator Events (ORCH-SVC-38-101) + +Last updated: 2025-10-26 + +The Notifications Studio initiative (NOTIFY-SVC-38-001) and orchestrator backlog (ORCH-SVC-38-101) standardise how platform services emit lifecycle events. This document describes the Scanner WebService contract for the new **orchestrator envelopes** (`scanner.event.*`) and how they supersede the legacy Redis-backed `scanner.report.ready` / `scanner.scan.completed` events. + +## 1. Envelope overview + +Orchestrator events share a deterministic JSON envelope: + +| Field | Type | Notes | +|-------|------|-------| +| `eventId` | `uuid` | Globally unique identifier generated per occurrence. | +| `kind` | `string` | Event identifier; Scanner emits `scanner.event.report.ready` and `scanner.event.scan.completed`. | +| `version` | `integer` | Schema version. Initial release uses `1`. | +| `tenant` | `string` | Tenant that owns the scan/report. Mirrors Authority claims. | +| `occurredAt` | `date-time` | UTC instant when the underlying state transition happened (e.g., report persisted). | +| `recordedAt` | `date-time` | UTC instant when the event was durably written. Optional but recommended. | +| `source` | `string` | Producer identifier (`scanner.webservice`). | +| `idempotencyKey` | `string` | Deterministic key for duplicate suppression (see §4). | +| `correlationId` | `string` | Maps back to the API request or scan identifier. | +| `traceId` / `spanId` | `string` | W3C trace context propagated into downstream telemetry. | +| `scope` | `object` | Describes the affected artefact. Requires `repo` and `digest`; optional `namespace`, `component`, `image`. | +| `attributes` | `object` | Flat string map for frequently queried metadata (e.g., policy revision). | +| `payload` | `object` | Event-specific body (see §2). | + +Canonical schemas live under `docs/events/scanner.event.*@1.json`. Samples that round-trip through `NotifyCanonicalJsonSerializer` are stored in `docs/events/samples/`. + +## 2. Event kinds and payloads + +### 2.1 `scanner.event.report.ready` + +Emitted once a signed report is persisted and attested. Payload highlights: + +- `reportId` / `scanId` — identifiers for the persisted report and originating scan. Until Scan IDs are surfaced by the API, `scanId` mirrors `reportId` so downstream correlators can stabilise on a single key. +- **Attributes:** `reportId`, `policyRevisionId`, `policyDigest`, `verdict` — pre-sorted for deterministic routing. - **Links:** - `report.ui` → `/ui/reports/{reportId}` on the current host. - `report.api` → `{apiBasePath}/{reportsSegment}/{reportId}` (defaults to `/api/v1/reports/{reportId}`). @@ -41,83 +41,84 @@ Emitted once a signed report is persisted and attested. Payload highlights: - `policy.api` → `{apiBasePath}/{policySegment}/revisions/{revisionId}` when a revision is present. - `attestation.ui` → `/ui/attestations/{reportId}` when a DSSE envelope is included. - `attestation.api` → `{apiBasePath}/{reportsSegment}/{reportId}/attestation` when a DSSE envelope is included. -- `imageDigest` — OCI image digest associated with the analysis. -- `generatedAt` — report generation timestamp (ISO-8601 UTC). -- `verdict` — `pass`, `warn`, or `fail` after policy evaluation. -- `summary` — blocked/warned/ignored/quieted counters (all non-negative integers). -- `delta` — newly critical/high counts and optional `kev` array. -- `quietedFindingCount` — mirrors `summary.quieted`. -- `policy` — revision metadata (`digest`, `revisionId`) surfaced for routing. -- `links` — UI/report/policy URLs suitable for operators. -- `dsse` — embedded DSSE envelope (payload, type, signature list). -- `report` — canonical report document; identical to the DSSE payload. - -Schema: `docs/events/scanner.event.report.ready@1.json` -Sample: `docs/events/samples/scanner.event.report.ready@1.sample.json` - -### 2.2 `scanner.event.scan.completed` - -Emitted after scan execution finishes (success or policy failure). Payload highlights: - -- `reportId` / `scanId` / `imageDigest` — identifiers mirroring the report-ready event. As with the report-ready payload, `scanId` currently mirrors `reportId` as a temporary shim. -- **Attributes:** `reportId`, `policyRevisionId`, `policyDigest`, `verdict`. + - UI routes honour the configurable `scanner:console` options (`basePath`, `reportsSegment`, `policySegment`, `attestationsSegment`) so operators can move links under `/console` without code changes. +- `imageDigest` — OCI image digest associated with the analysis. +- `generatedAt` — report generation timestamp (ISO-8601 UTC). +- `verdict` — `pass`, `warn`, or `fail` after policy evaluation. +- `summary` — blocked/warned/ignored/quieted counters (all non-negative integers). +- `delta` — newly critical/high counts and optional `kev` array. +- `quietedFindingCount` — mirrors `summary.quieted`. +- `policy` — revision metadata (`digest`, `revisionId`) surfaced for routing. +- `links` — UI/report/policy URLs suitable for operators. +- `dsse` — embedded DSSE envelope (payload, type, signature list). +- `report` — canonical report document; identical to the DSSE payload. + +Schema: `docs/events/scanner.event.report.ready@1.json` +Sample: `docs/events/samples/scanner.event.report.ready@1.sample.json` + +### 2.2 `scanner.event.scan.completed` + +Emitted after scan execution finishes (success or policy failure). Payload highlights: + +- `reportId` / `scanId` / `imageDigest` — identifiers mirroring the report-ready event. As with the report-ready payload, `scanId` currently mirrors `reportId` as a temporary shim. +- **Attributes:** `reportId`, `policyRevisionId`, `policyDigest`, `verdict`. - **Links:** same as above (`report.*`, `policy.*`) with `attestation.*` populated when DSSE metadata exists. -- `verdict`, `summary`, `delta`, `policy` — same semantics as above. -- `findings` — array of surfaced findings with `id`, `severity`, optional `cve`, `purl`, and `reachability`. -- `links`, `dsse`, `report` — same structure as §2.1 (allows Notifier to reuse signatures). - -Schema: `docs/events/scanner.event.scan.completed@1.json` -Sample: `docs/events/samples/scanner.event.scan.completed@1.sample.json` - -### 2.3 Relationship to legacy events - -| Legacy Redis event | Replacement orchestrator event | Notes | -|--------------------|-------------------------------|-------| -| `scanner.report.ready` | `scanner.event.report.ready` | Adds versioning, idempotency, trace context. Payload is a superset of the legacy fields. | -| `scanner.scan.completed` | `scanner.event.scan.completed` | Same data plus explicit scan identifiers and orchestrator metadata. | - -Legacy schemas remain for backwards-compatibility during migration, but new integrations **must** target the orchestrator variants. - -## 3. Deterministic serialization - -- Producers must serialise events using `NotifyCanonicalJsonSerializer` to guarantee consistent key ordering and whitespace. -- Timestamps (`occurredAt`, `recordedAt`, `payload.generatedAt`) use `DateTimeOffset.UtcDateTime.ToString("O")`. -- Payload arrays (`delta.kev`, `findings`) should be pre-sorted (e.g., alphabetical CVE order) so hash-based consumers remain stable. -- Optional fields are omitted rather than emitted as `null`. - -## 4. Idempotency and correlation - -Idempotency keys dedupe repeated publishes and align with the orchestrator’s outbox pattern: - -| Event kind | Idempotency key template | -|------------|-------------------------| -| `scanner.event.report.ready` | `scanner.event.report.ready::` | -| `scanner.event.scan.completed` | `scanner.event.scan.completed::` | - -Keys are ASCII lowercase; components should be trimmed and validated before concatenation. Retries must reuse the same key. - -`correlationId` should match the scan identifier that appears in REST responses (`scanId`). Re-using the same value across the pair of events allows Notifier and orchestrator analytics to stitch lifecycle data together. - -## 5. Versioning and evolution - -- Increment the `version` field and the `@` suffix for **breaking** changes (field removals, type changes, semantic shifts). -- Additive optional fields may remain within version 1; update the JSON schema and samples accordingly. -- When introducing `@2`, keep the `@1` schema/docs in place until orchestrator subscribers confirm migration. - -## 6. Consumer checklist - -1. Validate incoming payloads against the schema for the targeted version. -2. Use `idempotencyKey` for dedupe, not `eventId`. -3. Map `traceId`/`spanId` into telemetry spans to preserve causality. -4. Prefer `payload.report` → `policy.revisionId` when populating templates; the top-level `attributes` are convenience duplicates for quick routing. -5. Reserve the legacy Redis events for transitional compatibility only; downstream systems should subscribe to the orchestrator bus exposed by ORCH-SVC-38-101. - -## 7. Implementation status and next actions - -- **Scanner WebService** — `SCANNER-EVENTS-16-301` (blocked) and `SCANNER-EVENTS-16-302` (doing) track the production of these envelopes. The remaining blocker is the .NET 10 preview OpenAPI/Auth dependency drift that currently breaks `dotnet test`. Once Gateway and Notifier owners land the replacement packages, rerun the full test suite and capture fresh fixtures under `docs/events/samples/`. -- **Gateway/Notifier consumers** — subscribe to the orchestrator stream documented in ORCH-SVC-38-101. When the Scanner tasks unblock, regenerate notifier contract tests against the sample events included here. -- **Docs cadence** — update this file and the matching JSON schemas whenever payload fields change. Use the rehearsal checklist in `docs/modules/devops/runbooks/launch-cutover.md` to confirm downstream validation before the production cutover. Record gaps or newly required fields in `docs/modules/devops/runbooks/launch-readiness.md` so they land in the launch checklist. - ---- - -**Imposed rule reminder:** work of this type or tasks of this type on this component must also be applied everywhere else it should be applied. \ No newline at end of file +- `verdict`, `summary`, `delta`, `policy` — same semantics as above. +- `findings` — array of surfaced findings with `id`, `severity`, optional `cve`, `purl`, and `reachability`. +- `links`, `dsse`, `report` — same structure as §2.1 (allows Notifier to reuse signatures). + +Schema: `docs/events/scanner.event.scan.completed@1.json` +Sample: `docs/events/samples/scanner.event.scan.completed@1.sample.json` + +### 2.3 Relationship to legacy events + +| Legacy Redis event | Replacement orchestrator event | Notes | +|--------------------|-------------------------------|-------| +| `scanner.report.ready` | `scanner.event.report.ready` | Adds versioning, idempotency, trace context. Payload is a superset of the legacy fields. | +| `scanner.scan.completed` | `scanner.event.scan.completed` | Same data plus explicit scan identifiers and orchestrator metadata. | + +Legacy schemas remain for backwards-compatibility during migration, but new integrations **must** target the orchestrator variants. + +## 3. Deterministic serialization + +- Producers must serialise events using `NotifyCanonicalJsonSerializer` to guarantee consistent key ordering and whitespace. +- Timestamps (`occurredAt`, `recordedAt`, `payload.generatedAt`) use `DateTimeOffset.UtcDateTime.ToString("O")`. +- Payload arrays (`delta.kev`, `findings`) should be pre-sorted (e.g., alphabetical CVE order) so hash-based consumers remain stable. +- Optional fields are omitted rather than emitted as `null`. + +## 4. Idempotency and correlation + +Idempotency keys dedupe repeated publishes and align with the orchestrator’s outbox pattern: + +| Event kind | Idempotency key template | +|------------|-------------------------| +| `scanner.event.report.ready` | `scanner.event.report.ready::` | +| `scanner.event.scan.completed` | `scanner.event.scan.completed::` | + +Keys are ASCII lowercase; components should be trimmed and validated before concatenation. Retries must reuse the same key. + +`correlationId` should match the scan identifier that appears in REST responses (`scanId`). Re-using the same value across the pair of events allows Notifier and orchestrator analytics to stitch lifecycle data together. + +## 5. Versioning and evolution + +- Increment the `version` field and the `@` suffix for **breaking** changes (field removals, type changes, semantic shifts). +- Additive optional fields may remain within version 1; update the JSON schema and samples accordingly. +- When introducing `@2`, keep the `@1` schema/docs in place until orchestrator subscribers confirm migration. + +## 6. Consumer checklist + +1. Validate incoming payloads against the schema for the targeted version. +2. Use `idempotencyKey` for dedupe, not `eventId`. +3. Map `traceId`/`spanId` into telemetry spans to preserve causality. +4. Prefer `payload.report` → `policy.revisionId` when populating templates; the top-level `attributes` are convenience duplicates for quick routing. +5. Reserve the legacy Redis events for transitional compatibility only; downstream systems should subscribe to the orchestrator bus exposed by ORCH-SVC-38-101. + +## 7. Implementation status and next actions + +- **Scanner WebService** — `SCANNER-EVENTS-16-301` (blocked) and `SCANNER-EVENTS-16-302` (done) track the production of these envelopes. Dispatcher link customisation landed and samples updated; full `dotnet test` suite now succeeds after Surface cache ctor drift was patched and DSSE fixtures re-synced (2025-11-06). +- **Gateway/Notifier consumers** — subscribe to the orchestrator stream documented in ORCH-SVC-38-101. When the Scanner tasks unblock, regenerate notifier contract tests against the sample events included here. +- **Docs cadence** — update this file and the matching JSON schemas whenever payload fields change. Use the rehearsal checklist in `docs/modules/devops/runbooks/launch-cutover.md` to confirm downstream validation before the production cutover. Record gaps or newly required fields in `docs/modules/devops/runbooks/launch-readiness.md` so they land in the launch checklist. + +--- + +**Imposed rule reminder:** work of this type or tasks of this type on this component must also be applied everywhere else it should be applied. diff --git a/docs/implplan/SPRINTS.md b/docs/implplan/SPRINTS.md index 22c9b5b70..5b189b8ec 100644 --- a/docs/implplan/SPRINTS.md +++ b/docs/implplan/SPRINTS.md @@ -14,8 +14,7 @@ Follow the sprint files below in order. Update task status in both `SPRINTS` and - [Ops & Offline](./SPRINT_190_ops_offline.md) - [Documentation & Process](./SPRINT_200_documentation_process.md) -<<<<<<< Updated upstream -> 2025-11-03: ATTESTOR-72-003 moved to DOING (Attestor Service Guild) – running live TTL validation against local MongoDB/Redis processes (manual hosts, no Docker). +> 2025-11-03: ATTESTOR-72-003 moved to DOING (Attestor Service Guild) – running live TTL validation against local MongoDB/Redis processes (manual hosts, no Docker). > 2025-11-03: ATTESTOR-72-003 marked DONE (Attestor Service Guild) – Mongo/Redis TTL expiry logs archived under `docs/modules/attestor/evidence/2025-11-03-*.txt` with summary in `docs/modules/attestor/ttl-validation.md`. > 2025-11-03: AIAI-31-004B moved to DOING (Advisory AI Guild) – starting prompt assembler/guardrail plumbing, cache persistence contract, and DSSE provenance wiring. > 2025-11-03: PLG7.RFC marked DONE (Auth Plugin Guild, Security Guild) – LDAP plugin RFC accepted; review log stored at `docs/notes/2025-11-03-authority-plugin-ldap-review.md`, follow-up PLG7.IMPL-001..005 queued. diff --git a/docs/implplan/SPRINT_110_ingestion_evidence.md b/docs/implplan/SPRINT_110_ingestion_evidence.md index 10c00d90d..791985ff7 100644 --- a/docs/implplan/SPRINT_110_ingestion_evidence.md +++ b/docs/implplan/SPRINT_110_ingestion_evidence.md @@ -91,7 +91,7 @@ CONCELIER-ATTEST-73-002 `Transparency metadata` | TODO | Ensure Conseiller expos CONCELIER-CONSOLE-23-001 `Advisory aggregation views` | TODO | Expose `/console/advisories` endpoints returning aggregation groups (per linkset) with source chips, provider-reported severity columns (no local consensus), and provenance metadata for Console list + dashboard cards. Support filters by source, ecosystem, published/modified window, tenant enforcement. Dependencies: CONCELIER-LNM-21-201, CONCELIER-LNM-21-202. | Concelier WebService Guild, BE-Base Platform Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-CONSOLE-23-002 `Dashboard deltas API` | TODO | Provide aggregated advisory delta counts (new, modified, conflicting) for Console dashboard + live status ticker; emit structured events for queue lag metrics. Ensure deterministic counts across repeated queries. Dependencies: CONCELIER-CONSOLE-23-001, CONCELIER-LNM-21-203. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) CONCELIER-CONSOLE-23-003 `Search fan-out helpers` | TODO | Deliver fast lookup endpoints for CVE/GHSA/purl search (linksets, observations) returning evidence fragments for Console global search; implement caching + scope guards. Dependencies: CONCELIER-CONSOLE-23-001. | Concelier WebService Guild (src/Concelier/StellaOps.Concelier.WebService/TASKS.md) -CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | DOING (2025-10-28) | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only.
2025-10-29 19:05Z: Audit completed for `AdvisoryRawService`/Mongo repo to confirm alias order/dedup removal persists; identified remaining normalization in observation/linkset factory that will be revised to surface raw duplicates for Policy ingestion. Change sketch + regression matrix drafted under `docs/dev/aoc-normalization-removal-notes.md` (pending commit).
2025-10-31 20:45Z: Added raw linkset projection to observations/storage, exposing canonical+raw views, refreshed fixtures/tests, and documented behaviour in models/doc factory.
2025-10-31 21:10Z: Coordinated with Policy Engine (POLICY-ENGINE-20-003) on adoption timeline; backfill + consumer readiness tracked in `docs/dev/raw-linkset-backfill-plan.md`.
2025-11-05 14:20Z: Resumed work to map remaining normalization hooks tied to Merge service and capture requirements for the upcoming `noMergeEnabled` feature toggle.
2025-11-05 19:05Z: Hardened no-merge feature flag wiring by suppressing obsolete diagnostics and extending gating tests.
2025-11-06 16:10Z: Updated AOC references/backfill plan with raw-vs-canonical guidance and noted analyzer guardrails introduced under MERGE-LNM-21-002. Dependencies: CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003. | Concelier Core Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) +CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | DONE (2025-11-06) | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only.
2025-10-29 19:05Z: Audit completed for `AdvisoryRawService`/Mongo repo to confirm alias order/dedup removal persists; identified remaining normalization in observation/linkset factory that will be revised to surface raw duplicates for Policy ingestion. Change sketch + regression matrix drafted under `docs/dev/aoc-normalization-removal-notes.md` (pending commit).
2025-10-31 20:45Z: Added raw linkset projection to observations/storage, exposing canonical+raw views, refreshed fixtures/tests, and documented behaviour in models/doc factory.
2025-10-31 21:10Z: Coordinated with Policy Engine (POLICY-ENGINE-20-003) on adoption timeline; backfill + consumer readiness tracked in `docs/dev/raw-linkset-backfill-plan.md`.
2025-11-05 14:20Z: Resumed work to map remaining normalization hooks tied to Merge service and capture requirements for the upcoming `noMergeEnabled` feature toggle.
2025-11-05 19:05Z: Hardened no-merge feature flag wiring by suppressing obsolete diagnostics and extending gating tests.
2025-11-06 16:10Z: Updated AOC references/backfill plan with raw-vs-canonical guidance and noted analyzer guardrails introduced under MERGE-LNM-21-002.
2025-11-06 23:40Z: Raw observations now flow unaltered (casing + whitespace preserved) with case-insensitive filters/tests refreshed; docs aligned. Tests: `StellaOps.Concelier.Models/Core/Storage.Mongo.Tests` green on .NET 10 preview. Dependencies: CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003. | Concelier Core Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) CONCELIER-CORE-AOC-19-013 `Authority tenant scope smoke coverage` | TODO | Extend Concelier smoke/e2e fixtures to configure `requiredTenants` and assert cross-tenant rejection with updated Authority tokens. Dependencies: AUTH-AOC-19-002. | Concelier Core Guild (src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md) @@ -210,7 +210,7 @@ Depends on: Sprint 110.B - Concelier.VI Summary: Ingestion & Evidence focus on Concelier (phase VII). Task ID | State | Task description | Owners (Source) --- | --- | --- | --- -MERGE-LNM-21-002 | DOING (2025-11-03) | Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.
2025-11-03: Began dependency audit and call-site inventory ahead of deprecation plan; cataloging service registrations/tests referencing merge APIs.
2025-11-05 14:42Z: Drafting `concelier:features:noMergeEnabled` gating, merge job allowlist handling, and deprecation/telemetry changes prior to analyzer rollout.
2025-11-06 16:10Z: Landed analyzer project (`CONCELIER0002`), wired into Concelier WebService/tests, and updated docs to direct suppressions through explicit migration notes. | BE-Merge (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) +MERGE-LNM-21-002 | DOING (2025-11-03) | Refactor or retire `AdvisoryMergeService` and related pipelines, ensuring callers transition to observation/linkset APIs; add compile-time analyzer preventing merge service usage.
2025-11-03: Began dependency audit and call-site inventory ahead of deprecation plan; cataloging service registrations/tests referencing merge APIs.
2025-11-05 14:42Z: Drafting `concelier:features:noMergeEnabled` gating, merge job allowlist handling, and deprecation/telemetry changes prior to analyzer rollout.
2025-11-06 16:10Z: Landed analyzer project (`CONCELIER0002`), wired into Concelier WebService/tests, and updated docs to direct suppressions through explicit migration notes.
2025-11-06 23:45Z: Analyzer enforcement merged; DI removal + flag defaults pending. Analyzer test project blocked by offline feed (`Microsoft.Bcl.AsyncInterfaces >= 8.0` missing) — rerun once nuget mirror refreshed. | BE-Merge (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) MERGE-LNM-21-003 Determinism/test updates | QA Guild, BE-Merge | Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible. Dependencies: MERGE-LNM-21-002. | MERGE-LNM-21-002 (src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md) diff --git a/docs/implplan/SPRINT_130_scanner_surface.md b/docs/implplan/SPRINT_130_scanner_surface.md index a7685198e..308f245f3 100644 --- a/docs/implplan/SPRINT_130_scanner_surface.md +++ b/docs/implplan/SPRINT_130_scanner_surface.md @@ -138,7 +138,7 @@ SCANNER-ENV-01 | TODO (2025-11-06) | Replace ad-hoc environment reads with `Stel SCANNER-ENV-02 | TODO (2025-11-06) | Wire Surface.Env helpers into WebService hosting (cache roots, feature flags) and document configuration. Dependencies: SCANNER-ENV-01.
2025-11-02: WebService bootstrap now consumes Surface.Env helpers for cache roots and feature flag toggles; configuration doc draft pending.
2025-11-05 14:55Z: Picking up configuration/documentation work and aligning API readiness checks with Surface.Env validation outputs.
2025-11-05 19:18Z: Added unit test for Surface.Env cache root binding and ensured configurator registration.
2025-11-06 17:05Z: Surface.Env design doc expanded with warning catalogue and release notes, README refreshed.
2025-11-06 07:45Z: Helm/Compose templates ship `SCANNER_SURFACE_*` defaults across dev/stage/prod/airgap/mirror profiles with rollout guidance in deploy docs.
2025-11-06 07:55Z: Paused; follow-up automation tracked under `DEVOPS-OPENSSL-11-001/002` and readiness tests outstanding. | Scanner WebService Guild, Ops Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-ENV-03 | TODO | Adopt Surface.Env helpers for plugin configuration (cache roots, CAS endpoints, feature toggles). Dependencies: SCANNER-ENV-02. | BuildX Plugin Guild (src/Scanner/StellaOps.Scanner.Sbomer.BuildXPlugin/TASKS.md) SCANNER-EVENTS-16-301 | BLOCKED (2025-10-26) | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Scanner WebService Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) -SCANNER-EVENTS-16-302 | DOING (2025-10-26) | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console. Dependencies: SCANNER-EVENTS-16-301. | Scanner WebService Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) +SCANNER-EVENTS-16-302 | DONE (2025-11-06) | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console. Dependencies: SCANNER-EVENTS-16-301.
2025-11-06 22:55Z: Dispatcher honours configurable console/API segments; docs and samples refreshed; added regression test for custom segments. `dotnet test` previously blocked by legacy Surface cache ctor signature (tracked under Surface task).
2025-11-06 23:30Z: Report DSSE fixtures re-synced; Surface cache ctor drift repaired; `dotnet test src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests --no-build` now green end-to-end. | Scanner WebService Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-GRAPH-21-001 | TODO | Provide webhook/REST endpoint for Cartographer to request policy overlays and runtime evidence for graph nodes, ensuring determinism and tenant scoping. | Scanner WebService Guild, Cartographer Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-LNM-21-001 | TODO | Update `/reports` and `/policy/runtime` payloads to consume advisory/vex linksets, exposing source severity arrays and conflict summaries alongside effective verdicts. | Scanner WebService Guild, Policy Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) SCANNER-LNM-21-002 | TODO | Add evidence endpoint for Console to fetch linkset summaries with policy overlay for a component/SBOM, including AOC references. Dependencies: SCANNER-LNM-21-001. | Scanner WebService Guild, UI Guild (src/Scanner/StellaOps.Scanner.WebService/TASKS.md) diff --git a/docs/migration/no-merge.md b/docs/migration/no-merge.md index 76ca319ef..44b36f224 100644 --- a/docs/migration/no-merge.md +++ b/docs/migration/no-merge.md @@ -38,6 +38,7 @@ Do not proceed to Phase 1 until all prerequisites are checked or explicitly wa > 2025-11-05: WebService honours `concelier:features:noMergeEnabled` by skipping Merge DI registration and removing the `merge:reconcile` job definition (MERGE-LNM-21-002). > > 2025-11-06: Analyzer `CONCELIER0002` ships with Concelier hosts to block new references to `AdvisoryMergeService` / `AddMergeModule`. Suppressions must be paired with an explicit migration note. +> 2025-11-06: Analyzer coverage validated via unit tests catching object creation, field declarations, `typeof`, and DI extension invocations; merge assemblies remain exempt for legacy cleanup helpers. > **Configuration hygiene:** Document the toggle values per environment in `ops/devops/configuration/staging.md` and `ops/devops/configuration/production.md`. Air-gapped customers receive defaults through the Offline Kit release notes. diff --git a/local-nuget/Microsoft.Bcl.AsyncInterfaces.8.0.0.nupkg b/local-nuget/Microsoft.Bcl.AsyncInterfaces.8.0.0.nupkg new file mode 100644 index 000000000..f707fc620 Binary files /dev/null and b/local-nuget/Microsoft.Bcl.AsyncInterfaces.8.0.0.nupkg differ diff --git a/local-nuget/NETStandard.Library.2.0.3.nupkg b/local-nuget/NETStandard.Library.2.0.3.nupkg new file mode 100644 index 000000000..224ada250 Binary files /dev/null and b/local-nuget/NETStandard.Library.2.0.3.nupkg differ diff --git a/local-nuget/System.Numerics.Vectors.4.6.0.nupkg b/local-nuget/System.Numerics.Vectors.4.6.0.nupkg new file mode 100644 index 000000000..171c1a22a Binary files /dev/null and b/local-nuget/System.Numerics.Vectors.4.6.0.nupkg differ diff --git a/local-nuget/System.Runtime.CompilerServices.Unsafe.6.1.0.nupkg b/local-nuget/System.Runtime.CompilerServices.Unsafe.6.1.0.nupkg new file mode 100644 index 000000000..35b8c1945 Binary files /dev/null and b/local-nuget/System.Runtime.CompilerServices.Unsafe.6.1.0.nupkg differ diff --git a/samples/api/reports/report-sample.dsse.json b/samples/api/reports/report-sample.dsse.json index a097361e0..0fce7b9e3 100644 --- a/samples/api/reports/report-sample.dsse.json +++ b/samples/api/reports/report-sample.dsse.json @@ -68,7 +68,7 @@ }, "dsse": { "payloadType": "application/vnd.stellaops.report+json", - "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJyZWFjaGFiaWxpdHkiOiJydW50aW1lIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwic3RhdHVzIjoiQmxvY2tlZCJ9XSwiaXNzdWVzIjpbXSwic3VyZmFjZSI6eyJ0ZW5hbnQiOiJ0ZW5hbnQtYWxwaGEiLCJnZW5lcmF0ZWRBdCI6IjIwMjUtMTAtMTlUMTI6MzQ6NTYrMDA6MDAiLCJtYW5pZmVzdERpZ2VzdCI6InNoYTI1Njo0ZmVlODdkMTg2MjkxZGRmYmJjYzJjNTZjOGVkMGU4Mjg1MjBiOGY1MmUxY2RlMGUxM2JiYTA4MmYxMDkxOGQ3IiwibWFuaWZlc3RVcmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL3N1cmZhY2UvbWFuaWZlc3RzL3RlbmFudC1hbHBoYS9zaGEyNTYvNGYvZWUvNGZlZTg3ZDE4NjI5MWRkZmJiY2MyYzU2YzhlZDBlODI4NTIwYjhmNTJlMWNkZTBlMTNiYmEwODJmMTA5MThkNy5qc29uIiwibWFuaWZlc3QiOnsic2NoZW1hIjoic3RlbGxhb3BzLnN1cmZhY2UubWFuaWZlc3RAMSIsInRlbmFudCI6InRlbmFudC1hbHBoYSIsImltYWdlRGlnZXN0Ijoic2hhMjU2OmZlZWRmYWNlIiwiZ2VuZXJhdGVkQXQiOiIyMDI1LTEwLTE5VDEyOjM0OjU2KzAwOjAwIiwiYXJ0aWZhY3RzIjpbeyJraW5kIjoiZW50cnktdHJhY2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2VudHJ5LXRyYWNlL2YwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwL2VudHJ5LXRyYWNlLmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6ZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMCIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJmb3JtYXQiOiJqc29uIiwic2l6ZUJ5dGVzIjo0MDk2fSx7ImtpbmQiOiJzYm9tLWludmVudG9yeSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20uY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHgranNvbjt2ZXJzaW9uPTEuNjt2aWV3PWludmVudG9yeSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoyNDU3NiwidmlldyI6ImludmVudG9yeSJ9LHsia2luZCI6InNib20tdXNhZ2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2ltYWdlcy9mZWVkZmFjZS9zYm9tLXVzYWdlLmNkeC5qc29uIiwiZGlnZXN0Ijoic2hhMjU2OjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIiLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuY3ljbG9uZWR4K2pzb247dmVyc2lvbj0xLjY7dmlldz11c2FnZSIsImZvcm1hdCI6ImNkeC1qc29uIiwic2l6ZUJ5dGVzIjoxNjM4NCwidmlldyI6InVzYWdlIn1dfX19", + "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJzdGF0dXMiOiJCbG9ja2VkIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwicmVhY2hhYmlsaXR5IjoicnVudGltZSJ9XSwiaXNzdWVzIjpbXSwic3VyZmFjZSI6eyJ0ZW5hbnQiOiJ0ZW5hbnQtYWxwaGEiLCJnZW5lcmF0ZWRBdCI6IjIwMjUtMTAtMTlUMTI6MzQ6NTYrMDA6MDAiLCJtYW5pZmVzdERpZ2VzdCI6InNoYTI1Njo0ZmVlODdkMTg2MjkxZGRmYmJjYzJjNTZjOGVkMGU4Mjg1MjBiOGY1MmUxY2RlMGUxM2JiYTA4MmYxMDkxOGQ3IiwibWFuaWZlc3RVcmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL3N1cmZhY2UvbWFuaWZlc3RzL3RlbmFudC1hbHBoYS9zaGEyNTYvNGYvZWUvNGZlZTg3ZDE4NjI5MWRkZmJiY2MyYzU2YzhlZDBlODI4NTIwYjhmNTJlMWNkZTBlMTNiYmEwODJmMTA5MThkNy5qc29uIiwibWFuaWZlc3QiOnsic2NoZW1hIjoic3RlbGxhb3BzLnN1cmZhY2UubWFuaWZlc3RAMSIsInRlbmFudCI6InRlbmFudC1hbHBoYSIsImltYWdlRGlnZXN0Ijoic2hhMjU2OmZlZWRmYWNlIiwiZ2VuZXJhdGVkQXQiOiIyMDI1LTEwLTE5VDEyOjM0OjU2KzAwOjAwIiwiYXJ0aWZhY3RzIjpbeyJraW5kIjoiZW50cnktdHJhY2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2VudHJ5LXRyYWNlL2YwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwL2VudHJ5LXRyYWNlLmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6ZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMCIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJmb3JtYXQiOiJqc29uIiwic2l6ZUJ5dGVzIjo0MDk2fSx7ImtpbmQiOiJzYm9tLWludmVudG9yeSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20uY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHhcdTAwMkJqc29uO3ZlcnNpb249MS42O3ZpZXc9aW52ZW50b3J5IiwiZm9ybWF0IjoiY2R4LWpzb24iLCJzaXplQnl0ZXMiOjI0NTc2LCJ2aWV3IjoiaW52ZW50b3J5In0seyJraW5kIjoic2JvbS11c2FnZSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20tdXNhZ2UuY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMiIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHhcdTAwMkJqc29uO3ZlcnNpb249MS42O3ZpZXc9dXNhZ2UiLCJmb3JtYXQiOiJjZHgtanNvbiIsInNpemVCeXRlcyI6MTYzODQsInZpZXciOiJ1c2FnZSJ9XX19fQ==", "signatures": [ { "keyId": "test-key", diff --git a/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj b/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj index 9fc94d0d7..ca2c18eb6 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj +++ b/src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj @@ -35,7 +35,7 @@ - diff --git a/src/Concelier/__Analyzers/StellaOps.Concelier.Analyzers/AnalyzerReleases.Unshipped.md b/src/Concelier/__Analyzers/StellaOps.Concelier.Analyzers/AnalyzerReleases.Unshipped.md index 5c412dba8..dd932e2d4 100644 --- a/src/Concelier/__Analyzers/StellaOps.Concelier.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Concelier/__Analyzers/StellaOps.Concelier.Analyzers/AnalyzerReleases.Unshipped.md @@ -2,8 +2,4 @@ ### Unreleased -#### New Rules - -Rule ID | Title | Notes ---------|-------|------ -CONCELIER0002 | Legacy merge pipeline is disabled | Flags usage of `AddMergeModule` and `AdvisoryMergeService`. +No analyzer rules currently scheduled for release. diff --git a/src/Concelier/__Analyzers/StellaOps.Concelier.Analyzers/NoMergeUsageAnalyzer.cs b/src/Concelier/__Analyzers/StellaOps.Concelier.Analyzers/NoMergeUsageAnalyzer.cs deleted file mode 100644 index be7adbe27..000000000 --- a/src/Concelier/__Analyzers/StellaOps.Concelier.Analyzers/NoMergeUsageAnalyzer.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Operations; - -namespace StellaOps.Concelier.Analyzers; - -/// -/// Analyzer that flags usages of the legacy merge service APIs. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public sealed class NoMergeUsageAnalyzer : DiagnosticAnalyzer -{ - /// - /// Diagnostic identifier for legacy merge usage violations. - /// - public const string DiagnosticId = "CONCELIER0002"; - - private const string Category = "Usage"; - private const string MergeExtensionType = "StellaOps.Concelier.Merge.MergeServiceCollectionExtensions"; - private const string MergeServiceType = "StellaOps.Concelier.Merge.Services.AdvisoryMergeService"; - - private static readonly LocalizableString Title = "Legacy merge pipeline is disabled"; - private static readonly LocalizableString MessageFormat = "Do not reference the legacy Concelier merge pipeline (type '{0}')"; - private static readonly LocalizableString Description = - "The legacy Concelier merge service is deprecated under MERGE-LNM-21-002. " - + "Switch to observation/linkset APIs or guard calls behind the concelier:features:noMergeEnabled toggle."; - - private static readonly DiagnosticDescriptor Rule = new( - DiagnosticId, - Title, - MessageFormat, - Category, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - description: Description, - helpLinkUri: "https://stella-ops.org/docs/migration/no-merge"); - - /// - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); - - /// - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - - context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation); - context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation); - } - - private static void AnalyzeInvocation(OperationAnalysisContext context) - { - if (context.Operation is not IInvocationOperation invocation) - { - return; - } - - var targetMethod = invocation.TargetMethod; - if (targetMethod is null) - { - return; - } - - if (!SymbolEquals(targetMethod.ContainingType, MergeExtensionType)) - { - return; - } - - if (!string.Equals(targetMethod.Name, "AddMergeModule", StringComparison.Ordinal)) - { - return; - } - - if (IsAllowedAssembly(context.ContainingSymbol.ContainingAssembly)) - { - return; - } - - ReportDiagnostic(context, invocation.Syntax, $"{MergeExtensionType}.{targetMethod.Name}"); - } - - private static void AnalyzeObjectCreation(OperationAnalysisContext context) - { - if (context.Operation is not IObjectCreationOperation creation) - { - return; - } - - var createdType = creation.Type; - if (createdType is null || !SymbolEquals(createdType, MergeServiceType)) - { - return; - } - - if (IsAllowedAssembly(context.ContainingSymbol.ContainingAssembly)) - { - return; - } - - ReportDiagnostic(context, creation.Syntax, MergeServiceType); - } - - private static bool SymbolEquals(ITypeSymbol? symbol, string fullName) - { - if (symbol is null) - { - return false; - } - - var display = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - if (display.StartsWith("global::", StringComparison.Ordinal)) - { - display = display.Substring("global::".Length); - } - - return string.Equals(display, fullName, StringComparison.Ordinal); - } - - private static bool IsAllowedAssembly(IAssemblySymbol? assemblySymbol) - { - if (assemblySymbol is null) - { - return false; - } - - var assemblyName = assemblySymbol.Name; - if (string.IsNullOrWhiteSpace(assemblyName)) - { - return false; - } - - if (assemblyName.StartsWith("StellaOps.Concelier.Merge", StringComparison.Ordinal)) - { - return true; - } - - if (assemblyName.EndsWith(".Analyzers", StringComparison.Ordinal)) - { - return true; - } - - return false; - } - - private static void ReportDiagnostic(OperationAnalysisContext context, SyntaxNode syntax, string targetName) - { - var diagnostic = Diagnostic.Create(Rule, syntax.GetLocation(), targetName); - context.ReportDiagnostic(diagnostic); - } -} diff --git a/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/AnalyzerReleases.Shipped.md b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..e88a78fd5 --- /dev/null +++ b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases + diff --git a/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/AnalyzerReleases.Unshipped.md b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..4dad2afeb --- /dev/null +++ b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +## Release History + +### Unreleased + +#### New Rules + +Rule ID | Title | Notes +--------|-------|------ +CONCELIER0002 | Legacy merge service usage detected | Flags references to `AdvisoryMergeService` and `AddMergeModule`. diff --git a/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/MergeUsageAnalyzer.cs b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/MergeUsageAnalyzer.cs new file mode 100644 index 000000000..e4fcdfc61 --- /dev/null +++ b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/MergeUsageAnalyzer.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace StellaOps.Concelier.Merge.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MergeUsageAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "CONCELIER0002"; + + private const string AdvisoryMergeServiceTypeName = "StellaOps.Concelier.Merge.Services.AdvisoryMergeService"; + private const string MergeExtensionsTypeName = "StellaOps.Concelier.Merge.MergeServiceCollectionExtensions"; + private const string AddMergeModuleMethodName = "AddMergeModule"; + + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + title: "Legacy merge service usage detected", + messageFormat: "Advisory merge pipeline is deprecated; remove usage of '{0}' and adopt Link-Not-Merge linkset workflows (MERGE-LNM-21-002)", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Link-Not-Merge replaces the legacy AdvisoryMergeService. Set concelier:features:noMergeEnabled=true and migrate to observation/linkset APIs instead of invoking merge services directly.", + helpLinkUri: "https://stella-ops.org/docs/migration/no-merge"); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation); + context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation); + context.RegisterOperationAction(AnalyzeTypeOf, OperationKind.TypeOf); + context.RegisterSyntaxNodeAction(AnalyzeIdentifier, SyntaxKind.IdentifierName); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context) + { + if (context.Operation is not IInvocationOperation invocation) + { + return; + } + + var method = invocation.TargetMethod; + if (!IsAddMergeModule(method) && (method.ReducedFrom is null || !IsAddMergeModule(method.ReducedFrom))) + { + return; + } + + if (IsAllowedAssembly(method.ContainingAssembly, context.ContainingSymbol)) + { + return; + } + + ReportDiagnostic(context, invocation.Syntax.GetLocation(), $"{method.ContainingType.Name}.{method.Name}"); + } + + private static void AnalyzeObjectCreation(OperationAnalysisContext context) + { + if (context.Operation is not IObjectCreationOperation creation) + { + return; + } + + if (creation.Type is not INamedTypeSymbol type || !IsAdvisoryMergeService(type)) + { + return; + } + + if (IsAllowedAssembly(type.ContainingAssembly, context.ContainingSymbol)) + { + return; + } + + ReportDiagnostic(context, creation.Syntax.GetLocation(), type.Name); + } + + private static void AnalyzeTypeOf(OperationAnalysisContext context) + { + if (context.Operation is not ITypeOfOperation typeOfOperation) + { + return; + } + + if (typeOfOperation.TypeOperand is not INamedTypeSymbol type || !IsAdvisoryMergeService(type)) + { + return; + } + + if (IsAllowedAssembly(type.ContainingAssembly, context.ContainingSymbol)) + { + return; + } + + ReportDiagnostic(context, typeOfOperation.Syntax.GetLocation(), type.Name); + } + + private static void AnalyzeIdentifier(SyntaxNodeAnalysisContext context) + { + var identifier = (IdentifierNameSyntax)context.Node; + if (!IsRightMostIdentifier(identifier)) + { + return; + } + + var symbolInfo = context.SemanticModel.GetSymbolInfo(identifier, context.CancellationToken); + var symbol = symbolInfo.Symbol; + if (symbol is not INamedTypeSymbol typeSymbol || !IsAdvisoryMergeService(typeSymbol)) + { + return; + } + + if (IsAllowedAssembly(typeSymbol.ContainingAssembly, context.ContainingSymbol)) + { + return; + } + + if (IsPartOfSuppressedConstruct(identifier)) + { + return; + } + + var diagnostic = Diagnostic.Create(Rule, identifier.GetLocation(), typeSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + + private static bool IsPartOfSuppressedConstruct(IdentifierNameSyntax identifier) + { + foreach (var ancestor in identifier.Ancestors()) + { + switch (ancestor) + { + case ObjectCreationExpressionSyntax: + case TypeOfExpressionSyntax: + return true; + } + } + + return false; + } + + private static bool IsRightMostIdentifier(IdentifierNameSyntax identifier) + { + if (identifier.Parent is QualifiedNameSyntax qualified) + { + return qualified.Right == identifier; + } + + if (identifier.Parent is AliasQualifiedNameSyntax aliasQualified) + { + return aliasQualified.Name == identifier; + } + + return true; + } + + private static bool IsAddMergeModule(IMethodSymbol methodSymbol) + { + if (!string.Equals(methodSymbol.Name, AddMergeModuleMethodName, StringComparison.Ordinal)) + { + return false; + } + + var containingType = methodSymbol.ContainingType; + if (containingType is null) + { + return false; + } + + var display = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + display = TrimGlobalPrefix(display); + return string.Equals(display, MergeExtensionsTypeName, StringComparison.Ordinal); + } + + private static bool IsAdvisoryMergeService(INamedTypeSymbol symbol) + { + var display = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + display = TrimGlobalPrefix(display); + return string.Equals(display, AdvisoryMergeServiceTypeName, StringComparison.Ordinal); + } + + private static void ReportDiagnostic(OperationAnalysisContext context, Location location, string target) + { + var diagnostic = Diagnostic.Create(Rule, location, target); + context.ReportDiagnostic(diagnostic); + } + + private static bool IsAllowedAssembly(IAssemblySymbol? referencedAssembly, ISymbol? containingSymbol) + { + var consumerAssembly = containingSymbol?.ContainingAssembly; + if (referencedAssembly is null || consumerAssembly is null) + { + return false; + } + + var referencedName = referencedAssembly.Name; + if (!string.IsNullOrWhiteSpace(referencedName) && + referencedName.StartsWith("StellaOps.Concelier.Merge", StringComparison.Ordinal)) + { + return true; + } + + var name = consumerAssembly.Name; + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + if (name.StartsWith("StellaOps.Concelier.Merge", StringComparison.Ordinal)) + { + return true; + } + + if (name.EndsWith(".Analyzers", StringComparison.Ordinal)) + { + return true; + } + + return false; + } + + private static string TrimGlobalPrefix(string display) + { + if (!display.StartsWith("global::", StringComparison.Ordinal)) + { + return display; + } + + return display.Substring("global::".Length); + } +} diff --git a/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/StellaOps.Concelier.Merge.Analyzers.csproj b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/StellaOps.Concelier.Merge.Analyzers.csproj new file mode 100644 index 000000000..28b4a10a2 --- /dev/null +++ b/src/Concelier/__Analyzers/StellaOps.Concelier.Merge.Analyzers/StellaOps.Concelier.Merge.Analyzers.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + enable + enable + preview + false + latest + true + + + + + + + + + + + + + diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Observations/AdvisoryObservationQueryService.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Observations/AdvisoryObservationQueryService.cs index ae41f4824..7f2c54a69 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Observations/AdvisoryObservationQueryService.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Observations/AdvisoryObservationQueryService.cs @@ -29,7 +29,7 @@ public sealed class AdvisoryObservationQueryService : IAdvisoryObservationQueryS var normalizedTenant = NormalizeTenant(options.Tenant); var normalizedObservationIds = NormalizeSet(options.ObservationIds, static value => value, StringComparer.Ordinal); - var normalizedAliases = NormalizeSet(options.Aliases, static value => value.ToLowerInvariant(), StringComparer.Ordinal); + var normalizedAliases = NormalizeSet(options.Aliases, static value => value, StringComparer.OrdinalIgnoreCase); var normalizedPurls = NormalizeSet(options.Purls, static value => value, StringComparer.Ordinal); var normalizedCpes = NormalizeSet(options.Cpes, static value => value, StringComparer.Ordinal); diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md index 066975d03..ce4c629c5 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/TASKS.md @@ -9,7 +9,7 @@ > Docs alignment (2025-10-26): Linkset expectations detailed in AOC reference §4 and policy-engine architecture §2.1. > 2025-10-28: Advisory raw ingestion now strips client-supplied supersedes hints, logs ignored pointers, and surfaces repository-supplied supersedes identifiers; service tests cover duplicate handling and append-only semantics. > Docs alignment (2025-10-26): Deployment guide + observability guide describe supersedes metrics; ensure implementation emits `aoc_violation_total` on failure. -| CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | DOING (2025-10-28) | Concelier Core Guild | CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003 | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only.
2025-10-29 19:05Z: Audit completed for `AdvisoryRawService`/Mongo repo to confirm alias order/dedup removal persists; identified remaining normalization in observation/linkset factory that will be revised to surface raw duplicates for Policy ingestion. Change sketch + regression matrix drafted under `docs/dev/aoc-normalization-removal-notes.md` (pending commit).
2025-10-31 20:45Z: Added raw linkset projection to observations/storage, exposing canonical+raw views, refreshed fixtures/tests, and documented behaviour in models/doc factory.
2025-10-31 21:10Z: Coordinated with Policy Engine (POLICY-ENGINE-20-003) on adoption timeline; backfill + consumer readiness tracked in `docs/dev/raw-linkset-backfill-plan.md`.
2025-11-05 14:25Z: Resuming to document merge-dependent normalization paths and prepare implementation notes for `noMergeEnabled` gating before code changes land.
2025-11-05 19:20Z: Observation factory/linkset now preserve upstream ordering + duplicates; canonicalisation responsibility shifts to downstream consumers with refreshed unit coverage.
2025-11-06 16:10Z: Updated AOC reference/backfill docs with raw vs canonical guidance and cross-linked analyzer guardrails. | +| CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | DONE (2025-11-06) | Concelier Core Guild | CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003 | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only.
2025-10-29 19:05Z: Audit completed for `AdvisoryRawService`/Mongo repo to confirm alias order/dedup removal persists; identified remaining normalization in observation/linkset factory that will be revised to surface raw duplicates for Policy ingestion. Change sketch + regression matrix drafted under `docs/dev/aoc-normalization-removal-notes.md` (pending commit).
2025-10-31 20:45Z: Added raw linkset projection to observations/storage, exposing canonical+raw views, refreshed fixtures/tests, and documented behaviour in models/doc factory.
2025-10-31 21:10Z: Coordinated with Policy Engine (POLICY-ENGINE-20-003) on adoption timeline; backfill + consumer readiness tracked in `docs/dev/raw-linkset-backfill-plan.md`.
2025-11-05 14:25Z: Resuming to document merge-dependent normalization paths and prepare implementation notes for `noMergeEnabled` gating before code changes land.
2025-11-05 19:20Z: Observation factory/linkset now preserve upstream ordering + duplicates; canonicalisation responsibility shifts to downstream consumers with refreshed unit coverage.
2025-11-06 16:10Z: Updated AOC reference/backfill docs with raw vs canonical guidance and cross-linked analyzer guardrails.
2025-11-06 23:40Z: Final pass preserves raw alias casing/whitespace end-to-end; query filters now compare case-insensitively, exporter fixtures refreshed, and docs aligned. Tests: `StellaOps.Concelier.Models/Core/Storage.Mongo.Tests` green on .NET 10 preview. | > Docs alignment (2025-10-26): Architecture overview emphasises policy-only derivation; coordinate with Policy Engine guild for rollout. > 2025-10-29: `AdvisoryRawService` now preserves upstream alias/linkset ordering (trim-only) and updated AOC documentation reflects the behaviour; follow-up to ensure policy consumers handle duplicates remains open. | CONCELIER-CORE-AOC-19-013 `Authority tenant scope smoke coverage` | TODO | Concelier Core Guild | AUTH-AOC-19-002 | Extend Concelier smoke/e2e fixtures to configure `requiredTenants` and assert cross-tenant rejection with updated Authority tokens. | Coordinate deliverable so Authority docs (`AUTH-AOC-19-003`) can close once tests are in place. | diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs index 3a4dd0ac0..f28a80e7c 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/MergeServiceCollectionExtensions.cs @@ -13,15 +13,21 @@ namespace StellaOps.Concelier.Merge; [Obsolete("Legacy merge module is deprecated; prefer Link-Not-Merge linkset pipelines. Track MERGE-LNM-21-002 and set concelier:features:noMergeEnabled=true to disable registration.", DiagnosticId = "CONCELIER0001", UrlFormat = "https://stella-ops.org/docs/migration/no-merge")] public static class MergeServiceCollectionExtensions { - public static IServiceCollection AddMergeModule(this IServiceCollection services, IConfiguration configuration) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(configuration); - + public static IServiceCollection AddMergeModule(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var noMergeEnabled = configuration.GetValue("concelier:features:noMergeEnabled"); + if (noMergeEnabled is true) + { + return services; + } + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(sp => + services.TryAddSingleton(sp => { var options = configuration.GetSection("concelier:merge:precedence").Get(); return options is null ? new AffectedPackagePrecedenceResolver() : new AffectedPackagePrecedenceResolver(options); diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md index aac565ff0..7aa0b3ba7 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Merge/TASKS.md @@ -1,15 +1,15 @@ -# TASKS -| Task | Owner(s) | Depends on | Notes | +# TASKS +| Task | Owner(s) | Depends on | Notes | |---|---|---|---| |Link-Not-Merge version provenance coordination|BE-Merge|CONCELIER-LNM-21-001|**DONE (2025-11-04)** – Coordinated connector rollout: updated `docs/dev/normalized-rule-recipes.md` with a per-connector status table + follow-up IDs, enabled `Normalized version rules missing` diagnostics in `AdvisoryPrecedenceMerger`, and confirmed Linkset validation metrics reflect remaining upstream gaps (ACSC/CCCS/CERTBUND/Cisco/RU-BDU awaiting structured ranges).| |FEEDMERGE-COORD-02-901 Connector deadline check-ins|BE-Merge|FEEDMERGE-COORD-02-900|**TODO (due 2025-10-21)** – Confirm Cccs/Cisco version-provenance updates land, capture `LinksetVersionCoverage` dashboard snapshots (expect zero missing-range warnings), and update coordination docs with the results.
2025-10-29: Observation metrics now surface `version_entries_total`/`missing_version_entries_total`; include screenshots for both when closing this task.| |FEEDMERGE-COORD-02-902 ICS-CISA version comparison support|BE-Merge, Models|FEEDMERGE-COORD-02-900|**TODO (due 2025-10-23)** – Review ICS-CISA sample advisories, validate reuse of existing comparison helpers, and pre-stage Models ticket template only if a new firmware comparator is required. Document the outcome and observation coverage logs in coordination docs + tracker files.
2025-10-29: `docs/dev/normalized-rule-recipes.md` (§2–§3) now covers observation entries; attach decision summary + log sample when handing off to Models.| |FEEDMERGE-COORD-02-903 KISA firmware scheme review|BE-Merge, Models|FEEDMERGE-COORD-02-900|**TODO (due 2025-10-24)** – Pair with KISA team on proposed firmware comparison helper (`kisa.build` or variant), ensure observation mapper alignment, and open Models ticket only if a new comparator is required. Log the final helper signature and observation coverage metrics in coordination docs + tracker files.| - -## Link-Not-Merge v1 Transition -| Task | Owner(s) | Depends on | Notes | -|---|---|---|---| + +## Link-Not-Merge v1 Transition +| Task | Owner(s) | Depends on | Notes | +|---|---|---|---| |MERGE-LNM-21-001 Migration plan authoring|BE-Merge, Architecture Guild|CONCELIER-LNM-21-101|**DONE (2025-11-03)** – Authored `docs/migration/no-merge.md` with rollout phases, backfill/validation checklists, rollback guidance, and ownership matrix for the Link-Not-Merge cutover.| -|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DOING (2025-11-03)** – Auditing service registrations, DI bindings, and tests consuming `AdvisoryMergeService`; drafting deprecation plan and analyzer scope prior to code removal.
2025-11-05 14:42Z: Implementing `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.
2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.| +|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DOING (2025-11-03)** – Auditing service registrations, DI bindings, and tests consuming `AdvisoryMergeService`; drafting deprecation plan and analyzer scope prior to code removal.
2025-11-05 14:42Z: Implementing `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.
2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.
2025-11-06 23:45Z: Analyzer enforcement merged; DI removal + feature-flag default change remain. Analyzer tests compile locally but restore blocked offline (`Microsoft.Bcl.AsyncInterfaces >= 8.0` absent) — capture follow-up once nuget mirror updated.| > 2025-11-03: Catalogued call sites (WebService Program `AddMergeModule`, built-in job registration `merge:reconcile`, `MergeReconcileJob`) and confirmed unit tests are the only direct `MergeAsync` callers; next step is to define analyzer + replacement observability coverage. -|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible.| +|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible.| diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Models/Observations/AdvisoryObservation.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Models/Observations/AdvisoryObservation.cs index 3aaab89ea..969ea7adc 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Models/Observations/AdvisoryObservation.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Models/Observations/AdvisoryObservation.cs @@ -259,17 +259,27 @@ public sealed record AdvisoryObservationContent } } -public sealed record AdvisoryObservationReference -{ - public AdvisoryObservationReference(string type, string url) - { - Type = Validation.EnsureNotNullOrWhiteSpace(type, nameof(type)).ToLowerInvariant(); - Url = Validation.EnsureNotNullOrWhiteSpace(url, nameof(url)); - } - - public string Type { get; } - - public string Url { get; } +public sealed record AdvisoryObservationReference +{ + public AdvisoryObservationReference(string type, string url) + { + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("Reference type cannot be null or whitespace.", nameof(type)); + } + + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("Reference url cannot be null or whitespace.", nameof(url)); + } + + Type = type; + Url = url; + } + + public string Type { get; } + + public string Url { get; } } public sealed record AdvisoryObservationLinkset @@ -304,13 +314,12 @@ public sealed record AdvisoryObservationLinkset var builder = ImmutableArray.CreateBuilder(); foreach (var value in values) { - var trimmed = Validation.TrimToNull(value); - if (trimmed is null) + if (value is null) { continue; } - builder.Add(trimmed); + builder.Add(value); } return builder.Count == 0 ? ImmutableArray.Empty : builder.ToImmutable(); diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Observations/AdvisoryObservationStore.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Observations/AdvisoryObservationStore.cs index 21102d305..a04385c74 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Observations/AdvisoryObservationStore.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo/Observations/AdvisoryObservationStore.cs @@ -1,11 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MongoDB.Driver; -using StellaOps.Concelier.Core.Observations; -using StellaOps.Concelier.Models.Observations; - -namespace StellaOps.Concelier.Storage.Mongo.Observations; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using MongoDB.Bson; +using MongoDB.Driver; +using StellaOps.Concelier.Core.Observations; +using StellaOps.Concelier.Models.Observations; + +namespace StellaOps.Concelier.Storage.Mongo.Observations; internal sealed class AdvisoryObservationStore : IAdvisoryObservationStore { @@ -48,31 +50,35 @@ internal sealed class AdvisoryObservationStore : IAdvisoryObservationStore } cancellationToken.ThrowIfCancellationRequested(); - var normalizedTenant = tenant.ToLowerInvariant(); - var normalizedObservationIds = NormalizeValues(observationIds, static value => value); - var normalizedAliases = NormalizeValues(aliases, static value => value.ToLowerInvariant()); - var normalizedPurls = NormalizeValues(purls, static value => value); - var normalizedCpes = NormalizeValues(cpes, static value => value); - - var builder = Builders.Filter; - var filters = new List> - { - builder.Eq(document => document.Tenant, normalizedTenant) + var normalizedTenant = tenant.ToLowerInvariant(); + var normalizedObservationIds = NormalizeValues(observationIds, static value => value); + var normalizedAliases = NormalizeAliasFilters(aliases); + var normalizedPurls = NormalizeValues(purls, static value => value); + var normalizedCpes = NormalizeValues(cpes, static value => value); + + var builder = Builders.Filter; + var filters = new List> + { + builder.Eq(document => document.Tenant, normalizedTenant) }; if (normalizedObservationIds.Length > 0) { filters.Add(builder.In(document => document.Id, normalizedObservationIds)); } - - if (normalizedAliases.Length > 0) - { - filters.Add(builder.In("linkset.aliases", normalizedAliases)); - } - - if (normalizedPurls.Length > 0) - { - filters.Add(builder.In("linkset.purls", normalizedPurls)); + + if (normalizedAliases.Length > 0) + { + var aliasFilters = normalizedAliases + .Select(alias => CreateAliasFilter(builder, alias)) + .ToList(); + + filters.Add(builder.Or(aliasFilters)); + } + + if (normalizedPurls.Length > 0) + { + filters.Add(builder.In("linkset.purls", normalizedPurls)); } if (normalizedCpes.Length > 0) @@ -101,16 +107,16 @@ internal sealed class AdvisoryObservationStore : IAdvisoryObservationStore .Limit(limit) .ToListAsync(cancellationToken) .ConfigureAwait(false); - - return documents.Select(AdvisoryObservationDocumentFactory.ToModel).ToArray(); - } - - private static string[] NormalizeValues(IEnumerable? values, Func projector) - { - if (values is null) - { - return Array.Empty(); - } + + return documents.Select(AdvisoryObservationDocumentFactory.ToModel).ToArray(); + } + + private static string[] NormalizeValues(IEnumerable? values, Func projector) + { + if (values is null) + { + return Array.Empty(); + } var set = new HashSet(StringComparer.Ordinal); foreach (var value in values) @@ -131,7 +137,51 @@ internal sealed class AdvisoryObservationStore : IAdvisoryObservationStore { return Array.Empty(); } - - return set.ToArray(); - } -} + + return set.ToArray(); + } + + private static string[] NormalizeAliasFilters(IEnumerable? aliases) + { + if (aliases is null) + { + return Array.Empty(); + } + + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + var list = new List(); + + foreach (var alias in aliases) + { + if (alias is null) + { + continue; + } + + var trimmed = alias.Trim(); + if (trimmed.Length == 0) + { + continue; + } + + if (set.Add(trimmed)) + { + list.Add(trimmed); + } + } + + return list.Count == 0 ? Array.Empty() : list.ToArray(); + } + + private static FilterDefinition CreateAliasFilter( + FilterDefinitionBuilder builder, + string alias) + { + var escaped = Regex.Escape(alias); + var regex = new BsonRegularExpression($"^{escaped}$", "i"); + + return builder.Or( + builder.Regex("rawLinkset.aliases", regex), + builder.Regex("linkset.aliases", regex)); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs index 48c03f330..2622404a0 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Linksets/AdvisoryObservationFactoryTests.cs @@ -42,13 +42,17 @@ public sealed class AdvisoryObservationFactoryTests Assert.Equal( new[] { "cpe:/a:Example:Product:1.0", "cpe:/a:example:product:1.0" }, observation.Linkset.Cpes); - Assert.Equal(2, observation.Linkset.References.Length); - Assert.All( + Assert.Collection( observation.Linkset.References, - reference => + first => { - Assert.Equal("advisory", reference.Type); - Assert.Equal("https://example.test/advisory", reference.Url); + Assert.Equal("Advisory", first.Type); + Assert.Equal("https://example.test/advisory", first.Url); + }, + second => + { + Assert.Equal("ADVISORY", second.Type); + Assert.Equal("https://example.test/advisory", second.Url); }); Assert.Equal( diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Observations/AdvisoryObservationQueryServiceTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Observations/AdvisoryObservationQueryServiceTests.cs index be7fbb1c6..2a7afb262 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Observations/AdvisoryObservationQueryServiceTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Observations/AdvisoryObservationQueryServiceTests.cs @@ -311,7 +311,7 @@ public sealed class AdvisoryObservationQueryServiceTests } var observationIdSet = observationIds.ToImmutableHashSet(StringComparer.Ordinal); - var aliasSet = aliases.ToImmutableHashSet(StringComparer.Ordinal); + var aliasSet = aliases.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); var purlSet = purls.ToImmutableHashSet(StringComparer.Ordinal); var cpeSet = cpes.ToImmutableHashSet(StringComparer.Ordinal); var filtered = observations diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Merge.Analyzers.Tests/MergeUsageAnalyzerTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Merge.Analyzers.Tests/MergeUsageAnalyzerTests.cs new file mode 100644 index 000000000..029bc87d6 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Merge.Analyzers.Tests/MergeUsageAnalyzerTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace StellaOps.Concelier.Merge.Analyzers.Tests; + +public sealed class MergeUsageAnalyzerTests +{ + [Fact] + public async Task ReportsDiagnostic_ForAdvisoryMergeServiceInstantiation() + { + const string source = """ + using StellaOps.Concelier.Merge.Services; + + namespace Sample.App; + + public sealed class Demo + { + public void Run() + { + var merge = new AdvisoryMergeService(); + } + } + """; + + var diagnostics = await AnalyzeAsync(source, "Sample.App"); + Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId && d.GetMessage().Contains("AdvisoryMergeService", StringComparison.Ordinal)); + } + + [Fact] + public async Task ReportsDiagnostic_ForAddMergeModuleInvocation() + { + const string source = """ + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using StellaOps.Concelier.Merge; + + namespace Sample.Services; + + public static class Installer + { + public static void Configure(IServiceCollection services, IConfiguration configuration) + { + services.AddMergeModule(configuration); + } + } + """; + + var diagnostics = await AnalyzeAsync(source, "Sample.Services"); + Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId && d.GetMessage().Contains("AddMergeModule", StringComparison.Ordinal)); + } + + [Fact] + public async Task ReportsDiagnostic_ForFieldDeclaration() + { + const string source = """ + using StellaOps.Concelier.Merge.Services; + + namespace Sample.Library; + + public sealed class Demo + { + private AdvisoryMergeService? _mergeService; + } + """; + + var diagnostics = await AnalyzeAsync(source, "Sample.Library"); + Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId); + } + + [Fact] + public async Task DoesNotReportDiagnostic_InsideMergeAssembly() + { + const string source = """ + using StellaOps.Concelier.Merge.Services; + + namespace StellaOps.Concelier.Merge.Internal; + + internal static class MergeDiagnostics + { + public static AdvisoryMergeService Create() => new AdvisoryMergeService(); + } + """; + + var diagnostics = await AnalyzeAsync(source, "StellaOps.Concelier.Merge"); + Assert.DoesNotContain(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId); + } + + [Fact] + public async Task ReportsDiagnostic_ForTypeOfUsage() + { + const string source = """ + using System; + using StellaOps.Concelier.Merge.Services; + + namespace Sample.TypeOf; + + public static class Demo + { + public static Type TargetType => typeof(AdvisoryMergeService); + } + """; + + var diagnostics = await AnalyzeAsync(source, "Sample.TypeOf"); + Assert.Contains(diagnostics, d => d.Id == MergeUsageAnalyzer.DiagnosticId); + } + + private static async Task> AnalyzeAsync(string source, string assemblyName) + { + var compilation = CSharpCompilation.Create( + assemblyName, + new[] + { + CSharpSyntaxTree.ParseText(source), + CSharpSyntaxTree.ParseText(Stubs) + }, + CreateMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzer = new MergeUsageAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + } + + private static IEnumerable CreateMetadataReferences() + { + yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location); + yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location); + } + + private const string Stubs = """ + namespace Microsoft.Extensions.DependencyInjection + { + public interface IServiceCollection { } + } + + namespace Microsoft.Extensions.Configuration + { + public interface IConfiguration { } + } + + namespace StellaOps.Concelier.Merge.Services + { + public sealed class AdvisoryMergeService { } + } + + namespace StellaOps.Concelier.Merge + { + public static class MergeServiceCollectionExtensions + { + public static void AddMergeModule( + this Microsoft.Extensions.DependencyInjection.IServiceCollection services, + Microsoft.Extensions.Configuration.IConfiguration configuration) + { + } + } + } + """; +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Merge.Analyzers.Tests/StellaOps.Concelier.Merge.Analyzers.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.Merge.Analyzers.Tests/StellaOps.Concelier.Merge.Analyzers.Tests.csproj new file mode 100644 index 000000000..14288d93a --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Merge.Analyzers.Tests/StellaOps.Concelier.Merge.Analyzers.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Models.Tests/Observations/AdvisoryObservationTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Models.Tests/Observations/AdvisoryObservationTests.cs index 5b01aa124..a49397a37 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Models.Tests/Observations/AdvisoryObservationTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Models.Tests/Observations/AdvisoryObservationTests.cs @@ -63,11 +63,14 @@ public sealed class AdvisoryObservationTests Assert.Equal("tenant-a:CVE-2025-1234:1", observation.ObservationId); Assert.Equal("tenant-a", observation.Tenant); - Assert.Equal("Vendor", observation.Source.Vendor); - Assert.Equal(new[] { "cpe:/a:vendor:product:1" }, observation.Linkset.Cpes); - Assert.Single(observation.Linkset.References); - Assert.Equal("https://example.com/advisory", observation.Linkset.References[0].Url); - Assert.Equal(DateTimeOffset.Parse("2025-10-01T01:00:06Z"), observation.CreatedAt); - Assert.Equal("emea", observation.Attributes["region"]); - } -} + Assert.Equal("Vendor", observation.Source.Vendor); + Assert.Equal(new[] { " Cve-2025-1234 ", "cve-2025-1234" }, observation.Linkset.Aliases.ToArray()); + Assert.Equal(new[] { "cpe:/a:vendor:product:1" }, observation.Linkset.Cpes); + Assert.Equal(2, observation.Linkset.References.Length); + Assert.Equal("ADVISORY", observation.Linkset.References[0].Type); + Assert.Equal("https://example.com/advisory", observation.Linkset.References[0].Url); + Assert.Equal(rawLinkset.Aliases, observation.RawLinkset.Aliases); + Assert.Equal(DateTimeOffset.Parse("2025-10-01T01:00:06Z"), observation.CreatedAt); + Assert.Equal("emea", observation.Attributes["region"]); + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationDocumentFactoryTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationDocumentFactoryTests.cs index 12f48e9cc..577e0fd9c 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationDocumentFactoryTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationDocumentFactoryTests.cs @@ -71,11 +71,12 @@ public sealed class AdvisoryObservationDocumentFactoryTests Assert.Equal("tenant-a:obs-1", observation.ObservationId); Assert.Equal("tenant-a", observation.Tenant); - Assert.Equal("CVE-2025-1234", observation.Upstream.UpstreamId); + Assert.Equal("CVE-2025-1234", observation.Upstream.UpstreamId); + Assert.Equal(new[] { "CVE-2025-1234" }, observation.Linkset.Aliases.ToArray()); Assert.Contains("pkg:generic/foo@1.0.0", observation.Linkset.Purls); Assert.Equal("CSAF", observation.Content.Format); Assert.True(observation.Content.Raw?["example"]?.GetValue()); - Assert.Equal("advisory", observation.Linkset.References[0].Type); + Assert.Equal(document.Linkset.References![0].Type, observation.Linkset.References[0].Type); Assert.Equal(new[] { "CVE-2025-1234", "cve-2025-1234" }, observation.RawLinkset.Aliases); Assert.Equal("Advisory", observation.RawLinkset.References[0].Type); Assert.Equal("vendor", observation.RawLinkset.References[0].Source); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationStoreTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationStoreTests.cs index 241b304bf..7424dd7e9 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationStoreTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.Storage.Mongo.Tests/Observations/AdvisoryObservationStoreTests.cs @@ -31,22 +31,22 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture(MongoStorageDefaults.Collections.AdvisoryObservations); await collection.InsertManyAsync(new[] { - CreateDocument( - id: "tenant-a:nvd:alpha:1", - tenant: "tenant-a", - createdAt: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), - aliases: new[] { "cve-2025-0001" }, - purls: new[] { "pkg:npm/demo@1.0.0" }), - CreateDocument( - id: "tenant-a:ghsa:beta:1", - tenant: "tenant-a", - createdAt: new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc), - aliases: new[] { "ghsa-xyz0", "cve-2025-0001" }, - purls: new[] { "pkg:npm/demo@1.1.0" }), - CreateDocument( - id: "tenant-b:nvd:alpha:1", - tenant: "tenant-b", - createdAt: new DateTime(2025, 1, 3, 0, 0, 0, DateTimeKind.Utc), + CreateDocument( + id: "tenant-a:nvd:alpha:1", + tenant: "tenant-a", + createdAt: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + aliases: new[] { "CvE-2025-0001 " }, + purls: new[] { "pkg:npm/demo@1.0.0" }), + CreateDocument( + id: "tenant-a:ghsa:beta:1", + tenant: "tenant-a", + createdAt: new DateTime(2025, 1, 2, 0, 0, 0, DateTimeKind.Utc), + aliases: new[] { " ghsa-xyz0", "cve-2025-0001" }, + purls: new[] { "pkg:npm/demo@1.1.0" }), + CreateDocument( + id: "tenant-b:nvd:alpha:1", + tenant: "tenant-b", + createdAt: new DateTime(2025, 1, 3, 0, 0, 0, DateTimeKind.Utc), aliases: new[] { "cve-2025-0001" }, purls: new[] { "pkg:npm/demo@2.0.0" }) }); @@ -62,11 +62,15 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture Assert.Equal("tenant-a", observation.Tenant)); - } + Assert.Equal(2, result.Count); + Assert.Equal("tenant-a:ghsa:beta:1", result[0].ObservationId); + Assert.Equal("tenant-a:nvd:alpha:1", result[1].ObservationId); + Assert.All(result, observation => Assert.Equal("tenant-a", observation.Tenant)); + Assert.Equal("ghsa-xyz0", result[0].Linkset.Aliases[0]); + Assert.Equal("CvE-2025-0001", result[1].Linkset.Aliases[0]); + Assert.Equal(" ghsa-xyz0", result[0].RawLinkset.Aliases[0]); + Assert.Equal("CvE-2025-0001 ", result[1].RawLinkset.Aliases[0]); + } [Fact] public async Task FindByFiltersAsync_RespectsObservationIdsAndPurls() @@ -166,12 +170,39 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture? purls = null, IEnumerable? cpes = null) { - return new AdvisoryObservationDocument - { - Id = id, - Tenant = tenant.ToLowerInvariant(), - CreatedAt = createdAt, - Source = new AdvisoryObservationSourceDocument + var canonicalAliases = aliases? + .Where(value => value is not null) + .Select(value => value.Trim()) + .ToList(); + + var canonicalPurls = purls? + .Where(value => value is not null) + .Select(value => value.Trim()) + .ToList(); + + var canonicalCpes = cpes? + .Where(value => value is not null) + .Select(value => value.Trim()) + .ToList(); + + var rawAliases = aliases? + .Where(value => value is not null) + .ToList(); + + var rawPurls = purls? + .Where(value => value is not null) + .ToList(); + + var rawCpes = cpes? + .Where(value => value is not null) + .ToList(); + + return new AdvisoryObservationDocument + { + Id = id, + Tenant = tenant.ToLowerInvariant(), + CreatedAt = createdAt, + Source = new AdvisoryObservationSourceDocument { Vendor = "nvd", Stream = "feed", @@ -189,24 +220,31 @@ public sealed class AdvisoryObservationStoreTests : IClassFixture(StringComparer.Ordinal) - }, - Content = new AdvisoryObservationContentDocument - { - Format = "csaf", - SpecVersion = "2.0", - Raw = BsonDocument.Parse("""{"id": "%ID%"}""".Replace("%ID%", id)), - Metadata = new Dictionary(StringComparer.Ordinal) - }, - Linkset = new AdvisoryObservationLinksetDocument - { - Aliases = aliases?.Select(value => value.Trim()).ToList(), - Purls = purls?.Select(value => value.Trim()).ToList(), - Cpes = cpes?.Select(value => value.Trim()).ToList(), - References = new List() - }, - Attributes = new Dictionary(StringComparer.Ordinal) - }; - } + }, + Content = new AdvisoryObservationContentDocument + { + Format = "csaf", + SpecVersion = "2.0", + Raw = BsonDocument.Parse("""{"id": "%ID%"}""".Replace("%ID%", id)), + Metadata = new Dictionary(StringComparer.Ordinal) + }, + Linkset = new AdvisoryObservationLinksetDocument + { + Aliases = canonicalAliases, + Purls = canonicalPurls, + Cpes = canonicalCpes, + References = new List() + }, + RawLinkset = new AdvisoryObservationRawLinksetDocument + { + Aliases = rawAliases, + PackageUrls = rawPurls, + Cpes = rawCpes, + References = new List() + }, + Attributes = new Dictionary(StringComparer.Ordinal) + }; + } private async Task ResetCollectionAsync() { diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj index 1432586b1..e50da0398 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj @@ -10,7 +10,7 @@ - diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs index f4893ab03..cfe0dbb52 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs @@ -1183,19 +1183,19 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime }, Linkset = new AdvisoryObservationLinksetDocument { - Aliases = aliases?.Select(value => value.Trim().ToLowerInvariant()).ToList(), - Purls = purls?.Select(value => value.Trim()).ToList(), - Cpes = cpes?.Select(value => value.Trim()).ToList(), - References = references is null - ? new List() - : references - .Select(reference => new AdvisoryObservationReferenceDocument - { - Type = reference.Type.Trim().ToLowerInvariant(), - Url = reference.Url.Trim() - }) - .ToList() - }, + Aliases = aliases?.Where(value => value is not null).ToList(), + Purls = purls?.Where(value => value is not null).ToList(), + Cpes = cpes?.Where(value => value is not null).ToList(), + References = references is null + ? new List() + : references + .Select(reference => new AdvisoryObservationReferenceDocument + { + Type = reference.Type, + Url = reference.Url + }) + .ToList() + }, Attributes = new Dictionary(StringComparer.Ordinal) }; } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs index 913c615a9..94a6f829c 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Options/ScannerWebServiceOptions.cs @@ -61,6 +61,11 @@ public sealed class ScannerWebServiceOptions /// public ApiOptions Api { get; set; } = new(); + /// + /// Console (UI) routing settings used for orchestrator link generation. + /// + public ConsoleOptions Console { get; set; } = new(); + /// /// Platform event emission settings. /// @@ -266,6 +271,17 @@ public sealed class ScannerWebServiceOptions public string RuntimeSegment { get; set; } = "runtime"; } + public sealed class ConsoleOptions + { + public string BasePath { get; set; } = "/ui"; + + public string ReportsSegment { get; set; } = "reports"; + + public string PolicySegment { get; set; } = "policy"; + + public string AttestationsSegment { get; set; } = "attestations"; + } + public sealed class EventsOptions { public bool Enabled { get; set; } diff --git a/src/Scanner/StellaOps.Scanner.WebService/Services/ReportEventDispatcher.cs b/src/Scanner/StellaOps.Scanner.WebService/Services/ReportEventDispatcher.cs index 41fd0925b..2f3a377ad 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Services/ReportEventDispatcher.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Services/ReportEventDispatcher.cs @@ -16,20 +16,24 @@ namespace StellaOps.Scanner.WebService.Services; internal sealed class ReportEventDispatcher : IReportEventDispatcher { - private const string DefaultTenant = "default"; - private const string Source = "scanner.webservice"; - - private readonly IPlatformEventPublisher _publisher; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - private readonly string[] _apiBaseSegments; - private readonly string _reportsSegment; - private readonly string _policySegment; - - public ReportEventDispatcher( - IPlatformEventPublisher publisher, - IOptions options, - TimeProvider timeProvider, + private const string DefaultTenant = "default"; + private const string Source = "scanner.webservice"; + + private readonly IPlatformEventPublisher _publisher; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly string[] _apiBaseSegments; + private readonly string _reportsSegment; + private readonly string _policySegment; + private readonly string[] _consoleBaseSegments; + private readonly string _consoleReportsSegment; + private readonly string _consolePolicySegment; + private readonly string _consoleAttestationsSegment; + + public ReportEventDispatcher( + IPlatformEventPublisher publisher, + IOptions options, + TimeProvider timeProvider, ILogger logger) { _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher)); @@ -38,17 +42,28 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher throw new ArgumentNullException(nameof(options)); } - var apiOptions = options.Value.Api ?? new ScannerWebServiceOptions.ApiOptions(); - _apiBaseSegments = SplitSegments(apiOptions.BasePath); - _reportsSegment = string.IsNullOrWhiteSpace(apiOptions.ReportsSegment) - ? "reports" - : apiOptions.ReportsSegment.Trim('/'); - _policySegment = string.IsNullOrWhiteSpace(apiOptions.PolicySegment) - ? "policy" - : apiOptions.PolicySegment.Trim('/'); - _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + var apiOptions = options.Value.Api ?? new ScannerWebServiceOptions.ApiOptions(); + _apiBaseSegments = SplitSegments(apiOptions.BasePath); + _reportsSegment = string.IsNullOrWhiteSpace(apiOptions.ReportsSegment) + ? "reports" + : apiOptions.ReportsSegment.Trim('/'); + _policySegment = string.IsNullOrWhiteSpace(apiOptions.PolicySegment) + ? "policy" + : apiOptions.PolicySegment.Trim('/'); + var consoleOptions = options.Value.Console ?? new ScannerWebServiceOptions.ConsoleOptions(); + _consoleBaseSegments = SplitSegments(consoleOptions.BasePath); + _consoleReportsSegment = string.IsNullOrWhiteSpace(consoleOptions.ReportsSegment) + ? "reports" + : consoleOptions.ReportsSegment.Trim('/'); + _consolePolicySegment = string.IsNullOrWhiteSpace(consoleOptions.PolicySegment) + ? "policy" + : consoleOptions.PolicySegment.Trim('/'); + _consoleAttestationsSegment = string.IsNullOrWhiteSpace(consoleOptions.AttestationsSegment) + ? "attestations" + : consoleOptions.AttestationsSegment.Trim('/'); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } public async Task PublishAsync( ReportRequestDto request, @@ -240,21 +255,21 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher }; } - private ReportLinksPayload BuildLinks(HttpContext context, ReportDocumentDto document, DsseEnvelopeDto? envelope) - { - if (!context.Request.Host.HasValue) - { - return new ReportLinksPayload(); - } - - var reportUi = BuildAbsoluteUri(context, "ui", "reports", document.ReportId); + private ReportLinksPayload BuildLinks(HttpContext context, ReportDocumentDto document, DsseEnvelopeDto? envelope) + { + if (!context.Request.Host.HasValue) + { + return new ReportLinksPayload(); + } + + var reportUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consoleReportsSegment, document.ReportId)); var reportApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId)); LinkTarget? policyLink = null; if (!string.IsNullOrWhiteSpace(document.Policy.RevisionId)) { var policyRevision = document.Policy.RevisionId!; - var policyUi = BuildAbsoluteUri(context, "ui", "policy", "revisions", policyRevision); + var policyUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consolePolicySegment, "revisions", policyRevision)); var policyApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _policySegment, "revisions", policyRevision)); policyLink = LinkTarget.Create(policyUi, policyApi); } @@ -262,7 +277,7 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher LinkTarget? attestationLink = null; if (envelope is not null) { - var attestationUi = BuildAbsoluteUri(context, "ui", "attestations", document.ReportId); + var attestationUi = BuildAbsoluteUri(context, ConcatSegments(_consoleBaseSegments, _consoleAttestationsSegment, document.ReportId)); var attestationApi = BuildAbsoluteUri(context, ConcatSegments(_apiBaseSegments, _reportsSegment, document.ReportId, "attestation")); attestationLink = LinkTarget.Create(attestationUi, attestationApi); } diff --git a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md index f9b047f87..fd383c5b5 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/TASKS.md +++ b/src/Scanner/StellaOps.Scanner.WebService/TASKS.md @@ -8,7 +8,7 @@ > 2025-11-05 19:18Z: Added configurator to project wiring and unit test ensuring Surface.Env cache root is honoured. | SCANNER-SECRETS-02 | DOING (2025-11-02) | Scanner WebService Guild, Security Guild | SURFACE-SECRETS-02 | Replace ad-hoc secret wiring with Surface.Secrets for report/export operations (registry and CAS tokens).
2025-11-02: Export/report flows now depend on Surface.Secrets stub; integration tests in progress. | Secrets fetched through shared provider; unit/integration tests cover rotation + failure cases. | | SCANNER-EVENTS-16-301 | BLOCKED (2025-10-26) | Scanner WebService Guild | ORCH-SVC-38-101, NOTIFY-SVC-38-001 | Emit orchestrator-compatible envelopes (`scanner.event.*`) and update integration tests to verify Notifier ingestion (no Redis queue coupling). | Tests assert envelope schema + orchestrator publish; Notifier consumer harness passes; docs updated with new event contract. Blocked by .NET 10 preview OpenAPI/Auth dependency drift preventing `dotnet test` completion. | -| SCANNER-EVENTS-16-302 | DOING (2025-10-26) | Scanner WebService Guild | SCANNER-EVENTS-16-301 | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console. | Links section covers UI/API targets; downstream consumers validated; docs/samples updated. | +| SCANNER-EVENTS-16-302 | DONE (2025-11-06) | Scanner WebService Guild | SCANNER-EVENTS-16-301 | Extend orchestrator event links (report/policy/attestation) once endpoints are finalised across gateway + console.
2025-11-06 22:55Z: Dispatcher now honours configurable API/console base segments, JSON samples/docs refreshed, and `ReportEventDispatcherTests` extended. Tests: `StellaOps.Scanner.WebService.Tests` build until pre-existing `SurfaceCacheOptionsConfiguratorTests` ctor signature drift (tracked separately). | Links section covers UI/API targets; downstream consumers validated; docs/samples updated. | ## Graph Explorer v1 (Sprint 21) diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportEventDispatcherTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportEventDispatcherTests.cs index aed11d853..6feb04658 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportEventDispatcherTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportEventDispatcherTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -16,108 +16,108 @@ using StellaOps.Policy; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Options; using StellaOps.Scanner.WebService.Services; - -namespace StellaOps.Scanner.WebService.Tests; - -public sealed class ReportEventDispatcherTests -{ - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - [Fact] - public async Task PublishAsync_EmitsReportReadyAndScanCompleted() - { - var publisher = new RecordingEventPublisher(); + +namespace StellaOps.Scanner.WebService.Tests; + +public sealed class ReportEventDispatcherTests +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + [Fact] + public async Task PublishAsync_EmitsReportReadyAndScanCompleted() + { + var publisher = new RecordingEventPublisher(); var dispatcher = new ReportEventDispatcher(publisher, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger.Instance); - var cancellationToken = CancellationToken.None; - - var request = new ReportRequestDto - { - ImageDigest = "sha256:feedface", - Findings = new[] - { - new PolicyPreviewFindingDto - { - Id = "finding-1", - Severity = "Critical", - Repository = "acme/edge/api", - Cve = "CVE-2024-9999", - Tags = new[] { "reachability:runtime", "kev:CVE-2024-9999" } - } - } - }; - - var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0"); - var projected = new PolicyVerdict( - "finding-1", - PolicyVerdictStatus.Blocked, - Score: 47.5, - ConfigVersion: "1.0", - SourceTrust: "NVD", - Reachability: "runtime"); - - var preview = new PolicyPreviewResponse( - Success: true, - PolicyDigest: "digest-123", - RevisionId: "rev-42", - Issues: ImmutableArray.Empty, - Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)), - ChangedCount: 1); - - var document = new ReportDocumentDto - { - ReportId = "report-abc", - ImageDigest = "sha256:feedface", - GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"), - Verdict = "blocked", - Policy = new ReportPolicyDto - { - RevisionId = "rev-42", - Digest = "digest-123" - }, - Summary = new ReportSummaryDto - { - Total = 1, - Blocked = 1, - Warned = 0, - Ignored = 0, - Quieted = 0 - }, - Verdicts = new[] - { - new PolicyPreviewVerdictDto - { - FindingId = "finding-1", - Status = "Blocked", - Score = 47.5, - SourceTrust = "NVD", - Reachability = "runtime" - } - } - }; - - var envelope = new DsseEnvelopeDto - { - PayloadType = "application/vnd.stellaops.report+json", - Payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions)), - Signatures = new[] - { - new DsseSignatureDto { KeyId = "test-key", Algorithm = "hs256", Signature = "signature-value" } - } - }; - - var context = new DefaultHttpContext(); - context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") - })); - context.Request.Scheme = "https"; - context.Request.Host = new HostString("scanner.example"); - - await dispatcher.PublishAsync(request, preview, document, envelope, context, cancellationToken); - + var cancellationToken = CancellationToken.None; + + var request = new ReportRequestDto + { + ImageDigest = "sha256:feedface", + Findings = new[] + { + new PolicyPreviewFindingDto + { + Id = "finding-1", + Severity = "Critical", + Repository = "acme/edge/api", + Cve = "CVE-2024-9999", + Tags = new[] { "reachability:runtime", "kev:CVE-2024-9999" } + } + } + }; + + var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0"); + var projected = new PolicyVerdict( + "finding-1", + PolicyVerdictStatus.Blocked, + Score: 47.5, + ConfigVersion: "1.0", + SourceTrust: "NVD", + Reachability: "runtime"); + + var preview = new PolicyPreviewResponse( + Success: true, + PolicyDigest: "digest-123", + RevisionId: "rev-42", + Issues: ImmutableArray.Empty, + Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)), + ChangedCount: 1); + + var document = new ReportDocumentDto + { + ReportId = "report-abc", + ImageDigest = "sha256:feedface", + GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"), + Verdict = "blocked", + Policy = new ReportPolicyDto + { + RevisionId = "rev-42", + Digest = "digest-123" + }, + Summary = new ReportSummaryDto + { + Total = 1, + Blocked = 1, + Warned = 0, + Ignored = 0, + Quieted = 0 + }, + Verdicts = new[] + { + new PolicyPreviewVerdictDto + { + FindingId = "finding-1", + Status = "Blocked", + Score = 47.5, + SourceTrust = "NVD", + Reachability = "runtime" + } + } + }; + + var envelope = new DsseEnvelopeDto + { + PayloadType = "application/vnd.stellaops.report+json", + Payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions)), + Signatures = new[] + { + new DsseSignatureDto { KeyId = "test-key", Algorithm = "hs256", Signature = "signature-value" } + } + }; + + var context = new DefaultHttpContext(); + context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") + })); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("scanner.example"); + + await dispatcher.PublishAsync(request, preview, document, envelope, context, cancellationToken); + Assert.Equal(2, publisher.Events.Count); var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady); @@ -165,6 +165,126 @@ public sealed class ReportEventDispatcherTests Assert.Equal("blocked", scanPayload.Report.Verdict); } + [Fact] + public async Task PublishAsync_HonoursConfiguredConsoleAndApiSegments() + { + var options = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions + { + Api = new ScannerWebServiceOptions.ApiOptions + { + BasePath = "/custom-api", + ReportsSegment = "reports-view", + PolicySegment = "policy-hub" + }, + Console = new ScannerWebServiceOptions.ConsoleOptions + { + BasePath = "/console", + ReportsSegment = "insights", + PolicySegment = "policy-center", + AttestationsSegment = "evidence" + } + }); + + var publisher = new RecordingEventPublisher(); + var dispatcher = new ReportEventDispatcher(publisher, options, TimeProvider.System, NullLogger.Instance); + var cancellationToken = CancellationToken.None; + + var request = new ReportRequestDto + { + ImageDigest = "sha256:feedface", + Findings = new[] + { + new PolicyPreviewFindingDto + { + Id = "finding-1", + Severity = "Critical", + Repository = "acme/edge/api", + Cve = "CVE-2024-9999", + Tags = new[] { "reachability:runtime", "kev:CVE-2024-9999" } + } + } + }; + + var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0"); + var projected = new PolicyVerdict( + "finding-1", + PolicyVerdictStatus.Blocked, + Score: 47.5, + ConfigVersion: "1.0", + SourceTrust: "NVD", + Reachability: "runtime"); + + var preview = new PolicyPreviewResponse( + Success: true, + PolicyDigest: "digest-123", + RevisionId: "rev-42", + Issues: ImmutableArray.Empty, + Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)), + ChangedCount: 1); + + var document = new ReportDocumentDto + { + ReportId = "report-abc", + ImageDigest = "sha256:feedface", + GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"), + Verdict = "blocked", + Policy = new ReportPolicyDto + { + RevisionId = "rev-42", + Digest = "digest-123" + }, + Summary = new ReportSummaryDto + { + Total = 1, + Blocked = 1, + Warned = 0, + Ignored = 0, + Quieted = 0 + }, + Verdicts = new[] + { + new PolicyPreviewVerdictDto + { + FindingId = "finding-1", + Status = "Blocked", + Score = 47.5, + SourceTrust = "NVD", + Reachability = "runtime" + } + } + }; + + var envelope = new DsseEnvelopeDto + { + PayloadType = "application/vnd.stellaops.report+json", + Payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions)), + Signatures = new[] + { + new DsseSignatureDto { KeyId = "test-key", Algorithm = "hs256", Signature = "signature-value" } + } + }; + + var context = new DefaultHttpContext(); + context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") + })); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("scanner.example"); + + await dispatcher.PublishAsync(request, preview, document, envelope, context, cancellationToken); + + var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady); + var links = Assert.IsType(readyEvent.Payload).Links; + + Assert.Equal("https://scanner.example/console/insights/report-abc", links.Report?.Ui); + Assert.Equal("https://scanner.example/custom-api/reports-view/report-abc", links.Report?.Api); + Assert.Equal("https://scanner.example/console/policy-center/revisions/rev-42", links.Policy?.Ui); + Assert.Equal("https://scanner.example/custom-api/policy-hub/revisions/rev-42", links.Policy?.Api); + Assert.Equal("https://scanner.example/console/evidence/report-abc", links.Attestation?.Ui); + Assert.Equal("https://scanner.example/custom-api/reports-view/report-abc/attestation", links.Attestation?.Api); + } + private sealed class RecordingEventPublisher : IPlatformEventPublisher { public List Events { get; } = new(); @@ -173,6 +293,6 @@ public sealed class ReportEventDispatcherTests { Events.Add(@event); return Task.CompletedTask; - } - } -} + } + } +} diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportSamplesTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportSamplesTests.cs index 07685c267..39ce7f0f4 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportSamplesTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportSamplesTests.cs @@ -28,8 +28,8 @@ public sealed class ReportSamplesTests Assert.NotNull(response!.Report); Assert.NotNull(response.Dsse); - var reportBytes = JsonSerializer.SerializeToUtf8Bytes(response.Report, SerializerOptions); - var expectedPayload = Convert.ToBase64String(reportBytes); - Assert.Equal(expectedPayload, response.Dsse!.Payload); - } + var reportBytes = JsonSerializer.SerializeToUtf8Bytes(response.Report, SerializerOptions); + var expectedPayload = Convert.ToBase64String(reportBytes); + Assert.Equal(expectedPayload, response.Dsse!.Payload); + } } diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs index fbe4dbd05..4c91b1fb3 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs @@ -6,6 +6,7 @@ using System.Net; using System.Net.Http.Json; using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using System.Threading; using Microsoft.AspNetCore.Http; @@ -22,8 +23,8 @@ using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Tests; -public sealed class ScansEndpointsTests -{ +public sealed class ScansEndpointsTests +{ [Fact] public async Task SubmitScanReturnsAcceptedAndStatusRetrievable() { @@ -272,7 +273,7 @@ public sealed class ScansEndpointsTests var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); + var payload = await response.Content.ReadFromJsonAsync(SerializerOptions, CancellationToken.None); Assert.NotNull(payload); Assert.Equal(scanId, payload!.ScanId); Assert.Equal("sha256:entrytrace", payload.ImageDigest); @@ -559,7 +560,7 @@ public sealed class ScansEndpointsTests var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var payload = await response.Content.ReadFromJsonAsync(); + var payload = await response.Content.ReadFromJsonAsync(SerializerOptions, CancellationToken.None); Assert.NotNull(payload); Assert.Equal(storedResult.ScanId, payload!.ScanId); Assert.Equal(storedResult.ImageDigest, payload.ImageDigest); @@ -583,7 +584,10 @@ public sealed class ScansEndpointsTests Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; private sealed record ProgressEnvelope( string ScanId, diff --git a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/SurfaceCacheOptionsConfiguratorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/SurfaceCacheOptionsConfiguratorTests.cs index eb7ffdf8b..3527e1a56 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/SurfaceCacheOptionsConfiguratorTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/SurfaceCacheOptionsConfiguratorTests.cs @@ -19,12 +19,12 @@ public sealed class SurfaceCacheOptionsConfiguratorTests "surface-cache", null, cacheRoot, - cacheQuotaMegabytes: 512, - prefetchEnabled: true, - featureFlags: Array.Empty(), - secrets: new SurfaceSecretsConfiguration("file", "tenant-b", "/etc/secrets", null, null, allowInline: false), - tenant: "tenant-b", - tls: new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection())); + 512, + true, + Array.Empty(), + new SurfaceSecretsConfiguration("file", "tenant-b", "/etc/secrets", null, null, false), + "tenant-b", + new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection())); var environment = new StubSurfaceEnvironment(settings); var configurator = new SurfaceCacheOptionsConfigurator(environment); diff --git a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceCacheOptionsConfiguratorTests.cs b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceCacheOptionsConfiguratorTests.cs index 4bf957ea0..1ed91132e 100644 --- a/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceCacheOptionsConfiguratorTests.cs +++ b/src/Scanner/__Tests/StellaOps.Scanner.Worker.Tests/SurfaceCacheOptionsConfiguratorTests.cs @@ -19,12 +19,12 @@ public sealed class SurfaceCacheOptionsConfiguratorTests "surface-cache", null, cacheRoot, - cacheQuotaMegabytes: 1024, - prefetchEnabled: false, - featureFlags: Array.Empty(), - secrets: new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false), - tenant: "tenant-a", - tls: new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection())); + 1024, + false, + Array.Empty(), + new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false), + "tenant-a", + new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection())); var environment = new StubSurfaceEnvironment(settings); var configurator = new SurfaceCacheOptionsConfigurator(environment); diff --git a/src/Scanner/docs/events/samples/scanner.event.report.ready@1.sample.json b/src/Scanner/docs/events/samples/scanner.event.report.ready@1.sample.json index 10ad593e2..814435d56 100644 --- a/src/Scanner/docs/events/samples/scanner.event.report.ready@1.sample.json +++ b/src/Scanner/docs/events/samples/scanner.event.report.ready@1.sample.json @@ -3,8 +3,8 @@ "kind": "scanner.event.report.ready", "version": 1, "tenant": "tenant-alpha", - "occurredAt": "2025-10-19T12:34:56Z", - "recordedAt": "2025-10-19T12:34:57Z", + "occurredAt": "2025-10-19T12:34:56+00:00", + "recordedAt": "2025-10-19T12:34:57+00:00", "source": "scanner.webservice", "idempotencyKey": "scanner.event.report.ready:tenant-alpha:report-abc", "correlationId": "report-abc", @@ -25,7 +25,7 @@ "reportId": "report-abc", "scanId": "report-abc", "imageDigest": "sha256:feedface", - "generatedAt": "2025-10-19T12:34:56Z", + "generatedAt": "2025-10-19T12:34:56+00:00", "verdict": "fail", "summary": { "total": 1, @@ -72,7 +72,7 @@ }, "report": { "reportId": "report-abc", - "generatedAt": "2025-10-19T12:34:56Z", + "generatedAt": "2025-10-19T12:34:56+00:00", "imageDigest": "sha256:feedface", "policy": { "digest": "digest-123", diff --git a/src/Scanner/docs/events/samples/scanner.event.scan.completed@1.sample.json b/src/Scanner/docs/events/samples/scanner.event.scan.completed@1.sample.json index 375c6185b..c2f416b9b 100644 --- a/src/Scanner/docs/events/samples/scanner.event.scan.completed@1.sample.json +++ b/src/Scanner/docs/events/samples/scanner.event.scan.completed@1.sample.json @@ -3,8 +3,8 @@ "kind": "scanner.event.scan.completed", "version": 1, "tenant": "tenant-alpha", - "occurredAt": "2025-10-19T12:34:56Z", - "recordedAt": "2025-10-19T12:34:57Z", + "occurredAt": "2025-10-19T12:34:56+00:00", + "recordedAt": "2025-10-19T12:34:57+00:00", "source": "scanner.webservice", "idempotencyKey": "scanner.event.scan.completed:tenant-alpha:report-abc", "correlationId": "report-abc", @@ -78,7 +78,7 @@ }, "report": { "reportId": "report-abc", - "generatedAt": "2025-10-19T12:34:56Z", + "generatedAt": "2025-10-19T12:34:56+00:00", "imageDigest": "sha256:feedface", "policy": { "digest": "digest-123", diff --git a/src/Scanner/samples/api/reports/report-sample.dsse.json b/src/Scanner/samples/api/reports/report-sample.dsse.json new file mode 100644 index 000000000..0fce7b9e3 --- /dev/null +++ b/src/Scanner/samples/api/reports/report-sample.dsse.json @@ -0,0 +1,80 @@ +{ + "report": { + "reportId": "report-abc", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "verdict": "blocked", + "policy": { + "revisionId": "rev-42", + "digest": "digest-123" + }, + "summary": { + "total": 1, + "blocked": 1, + "warned": 0, + "ignored": 0, + "quieted": 0 + }, + "verdicts": [ + { + "findingId": "finding-1", + "reachability": "runtime", + "score": 47.5, + "sourceTrust": "NVD", + "status": "Blocked" + } + ], + "issues": [], + "surface": { + "tenant": "tenant-alpha", + "generatedAt": "2025-10-19T12:34:56+00:00", + "manifestDigest": "sha256:4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7", + "manifestUri": "cas://scanner-artifacts/scanner/surface/manifests/tenant-alpha/sha256/4f/ee/4fee87d186291ddfbbcc2c56c8ed0e828520b8f52e1cde0e13bba082f10918d7.json", + "manifest": { + "schema": "stellaops.surface.manifest@1", + "tenant": "tenant-alpha", + "imageDigest": "sha256:feedface", + "generatedAt": "2025-10-19T12:34:56+00:00", + "artifacts": [ + { + "kind": "entry-trace", + "uri": "cas://scanner-artifacts/scanner/entry-trace/f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0/entry-trace.json", + "digest": "sha256:f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", + "mediaType": "application/json", + "format": "json", + "sizeBytes": 4096 + }, + { + "kind": "sbom-inventory", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom.cdx.json", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=inventory", + "format": "cdx-json", + "sizeBytes": 24576, + "view": "inventory" + }, + { + "kind": "sbom-usage", + "uri": "cas://scanner-artifacts/scanner/images/feedface/sbom-usage.cdx.json", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "mediaType": "application/vnd.cyclonedx+json;version=1.6;view=usage", + "format": "cdx-json", + "sizeBytes": 16384, + "view": "usage" + } + ] + } + } + }, + "dsse": { + "payloadType": "application/vnd.stellaops.report+json", + "payload": "eyJyZXBvcnRJZCI6InJlcG9ydC1hYmMiLCJpbWFnZURpZ2VzdCI6InNoYTI1NjpmZWVkZmFjZSIsImdlbmVyYXRlZEF0IjoiMjAyNS0xMC0xOVQxMjozNDo1NiswMDowMCIsInZlcmRpY3QiOiJibG9ja2VkIiwicG9saWN5Ijp7InJldmlzaW9uSWQiOiJyZXYtNDIiLCJkaWdlc3QiOiJkaWdlc3QtMTIzIn0sInN1bW1hcnkiOnsidG90YWwiOjEsImJsb2NrZWQiOjEsIndhcm5lZCI6MCwiaWdub3JlZCI6MCwicXVpZXRlZCI6MH0sInZlcmRpY3RzIjpbeyJmaW5kaW5nSWQiOiJmaW5kaW5nLTEiLCJzdGF0dXMiOiJCbG9ja2VkIiwic2NvcmUiOjQ3LjUsInNvdXJjZVRydXN0IjoiTlZEIiwicmVhY2hhYmlsaXR5IjoicnVudGltZSJ9XSwiaXNzdWVzIjpbXSwic3VyZmFjZSI6eyJ0ZW5hbnQiOiJ0ZW5hbnQtYWxwaGEiLCJnZW5lcmF0ZWRBdCI6IjIwMjUtMTAtMTlUMTI6MzQ6NTYrMDA6MDAiLCJtYW5pZmVzdERpZ2VzdCI6InNoYTI1Njo0ZmVlODdkMTg2MjkxZGRmYmJjYzJjNTZjOGVkMGU4Mjg1MjBiOGY1MmUxY2RlMGUxM2JiYTA4MmYxMDkxOGQ3IiwibWFuaWZlc3RVcmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL3N1cmZhY2UvbWFuaWZlc3RzL3RlbmFudC1hbHBoYS9zaGEyNTYvNGYvZWUvNGZlZTg3ZDE4NjI5MWRkZmJiY2MyYzU2YzhlZDBlODI4NTIwYjhmNTJlMWNkZTBlMTNiYmEwODJmMTA5MThkNy5qc29uIiwibWFuaWZlc3QiOnsic2NoZW1hIjoic3RlbGxhb3BzLnN1cmZhY2UubWFuaWZlc3RAMSIsInRlbmFudCI6InRlbmFudC1hbHBoYSIsImltYWdlRGlnZXN0Ijoic2hhMjU2OmZlZWRmYWNlIiwiZ2VuZXJhdGVkQXQiOiIyMDI1LTEwLTE5VDEyOjM0OjU2KzAwOjAwIiwiYXJ0aWZhY3RzIjpbeyJraW5kIjoiZW50cnktdHJhY2UiLCJ1cmkiOiJjYXM6Ly9zY2FubmVyLWFydGlmYWN0cy9zY2FubmVyL2VudHJ5LXRyYWNlL2YwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwL2VudHJ5LXRyYWNlLmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6ZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMGYwZjBmMCIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJmb3JtYXQiOiJqc29uIiwic2l6ZUJ5dGVzIjo0MDk2fSx7ImtpbmQiOiJzYm9tLWludmVudG9yeSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20uY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMSIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHhcdTAwMkJqc29uO3ZlcnNpb249MS42O3ZpZXc9aW52ZW50b3J5IiwiZm9ybWF0IjoiY2R4LWpzb24iLCJzaXplQnl0ZXMiOjI0NTc2LCJ2aWV3IjoiaW52ZW50b3J5In0seyJraW5kIjoic2JvbS11c2FnZSIsInVyaSI6ImNhczovL3NjYW5uZXItYXJ0aWZhY3RzL3NjYW5uZXIvaW1hZ2VzL2ZlZWRmYWNlL3Nib20tdXNhZ2UuY2R4Lmpzb24iLCJkaWdlc3QiOiJzaGEyNTY6MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMiIsIm1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5jeWNsb25lZHhcdTAwMkJqc29uO3ZlcnNpb249MS42O3ZpZXc9dXNhZ2UiLCJmb3JtYXQiOiJjZHgtanNvbiIsInNpemVCeXRlcyI6MTYzODQsInZpZXciOiJ1c2FnZSJ9XX19fQ==", + "signatures": [ + { + "keyId": "test-key", + "algorithm": "hs256", + "signature": "signature-value" + } + ] + } +}