diff --git a/docs/api/concelier/concelier-lnm.yaml b/docs/api/concelier/concelier-lnm.yaml new file mode 100644 index 000000000..104ab608c --- /dev/null +++ b/docs/api/concelier/concelier-lnm.yaml @@ -0,0 +1,276 @@ +openapi: 3.1.0 +info: + title: StellaOps Concelier – Link-Not-Merge Policy APIs + version: "0.1.0" + description: Fact-only advisory/linkset retrieval for Policy Engine consumers. +servers: + - url: / + description: Relative base path (API Gateway rewrites in production). +tags: + - name: Linksets + description: Link-Not-Merge linkset retrieval +paths: + /v1/lnm/linksets: + get: + summary: List linksets + tags: [Linksets] + parameters: + - $ref: '#/components/parameters/Tenant' + - name: includeConflicts + in: query + required: false + schema: { type: boolean, default: true } + - name: includeObservations + in: query + required: false + schema: { type: boolean, default: false } + - $ref: '#/components/parameters/purl' + - $ref: '#/components/parameters/cpe' + - $ref: '#/components/parameters/ghsa' + - $ref: '#/components/parameters/cve' + - $ref: '#/components/parameters/advisoryId' + - $ref: '#/components/parameters/source' + - $ref: '#/components/parameters/severityMin' + - $ref: '#/components/parameters/severityMax' + - $ref: '#/components/parameters/publishedSince' + - $ref: '#/components/parameters/modifiedSince' + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/pageSize' + - $ref: '#/components/parameters/sort' + responses: + "200": + description: Deterministically ordered list of linksets + content: + application/json: + schema: + $ref: '#/components/schemas/PagedLinksets' + /v1/lnm/linksets/{advisoryId}: + get: + summary: Get linkset by advisory ID + tags: [Linksets] + parameters: + - $ref: '#/components/parameters/Tenant' + - name: advisoryId + in: path + required: true + schema: + type: string + - name: source + in: query + required: false + schema: { type: string } + - name: includeConflicts + in: query + required: false + schema: { type: boolean, default: true } + - name: includeObservations + in: query + required: false + schema: { type: boolean, default: false } + responses: + "200": + description: Linkset with provenance and conflicts + content: + application/json: + schema: + $ref: '#/components/schemas/Linkset' + "404": + description: Not found + /v1/lnm/linksets/search: + post: + summary: Search linksets (body filters) + tags: [Linksets] + parameters: + - $ref: '#/components/parameters/Tenant' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LinksetSearchRequest' + responses: + "200": + description: Deterministically ordered search results + content: + application/json: + schema: + $ref: '#/components/schemas/PagedLinksets' +components: + parameters: + Tenant: + name: Tenant + in: header + required: true + schema: + type: string + description: Tenant identifier (required). + purl: + name: purl + in: query + schema: + type: array + items: { type: string } + style: form + explode: true + cpe: + name: cpe + in: query + schema: { type: string } + ghsa: + name: ghsa + in: query + schema: { type: string } + cve: + name: cve + in: query + schema: { type: string } + advisoryId: + name: advisoryId + in: query + schema: { type: string } + source: + name: source + in: query + schema: + type: string + severityMin: + name: severityMin + in: query + schema: + type: number + format: float + severityMax: + name: severityMax + in: query + schema: + type: number + format: float + publishedSince: + name: publishedSince + in: query + schema: + type: string + format: date-time + modifiedSince: + name: modifiedSince + in: query + schema: + type: string + format: date-time + page: + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + pageSize: + name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + sort: + name: sort + in: query + schema: + type: string + enum: + - modifiedAt desc + - modifiedAt asc + - publishedAt desc + - publishedAt asc + - severity desc + - severity asc + - source + - advisoryId + description: Default modifiedAt desc; ties advisoryId asc, source asc. + schemas: + LinksetSearchRequest: + type: object + properties: + purl: { type: array, items: { type: string } } + cpe: { type: array, items: { type: string } } + ghsa: { type: string } + cve: { type: string } + advisoryId: { type: string } + source: { type: string } + severityMin: { type: number } + severityMax: { type: number } + publishedSince: { type: string, format: date-time } + modifiedSince: { type: string, format: date-time } + includeTimeline: { type: boolean, default: false } + includeObservations: { type: boolean, default: false } + includeConflicts: { type: boolean, default: true } + page: { type: integer, minimum: 1, default: 1 } + pageSize: { type: integer, minimum: 1, maximum: 200, default: 50 } + sort: { type: string, enum: [modifiedAt desc, modifiedAt asc, publishedAt desc, publishedAt asc, severity desc, severity asc, source, advisoryId] } + PagedLinksets: + type: object + properties: + items: + type: array + items: { $ref: '#/components/schemas/Linkset' } + page: { type: integer } + pageSize: { type: integer } + total: { type: integer } + Linkset: + type: object + required: [advisoryId, source, purl, cpe, provenance] + properties: + advisoryId: { type: string } + source: { type: string } + purl: { type: array, items: { type: string } } + cpe: { type: array, items: { type: string } } + summary: { type: string } + publishedAt: { type: string, format: date-time } + modifiedAt: { type: string, format: date-time } + severity: { type: string, description: Source-native severity label } + status: { type: string } + provenance: { $ref: '#/components/schemas/LinksetProvenance' } + conflicts: + type: array + items: { $ref: '#/components/schemas/LinksetConflict' } + timeline: + type: array + items: { $ref: '#/components/schemas/LinksetTimeline' } + normalized: + type: object + properties: + aliases: { type: array, items: { type: string } } + purl: { type: array, items: { type: string } } + versions: { type: array, items: { type: string } } + ranges: { type: array, items: { type: object } } + severities: { type: array, items: { type: object } } + cached: + type: boolean + description: True if served from cache; provenance.evidenceHash present for integrity. + remarks: + type: array + items: { type: string } + observations: + type: array + items: { type: string } + LinksetProvenance: + type: object + properties: + ingestedAt: { type: string, format: date-time } + connectorId: { type: string } + evidenceHash: { type: string } + dsseEnvelopeHash: { type: string } + LinksetConflict: + type: object + properties: + field: { type: string } + reason: { type: string } + observedValue: { type: string } + observedAt: { type: string, format: date-time } + evidenceHash: { type: string } + LinksetTimeline: + type: object + properties: + event: { type: string } + at: { type: string, format: date-time } + evidenceHash: { type: string } diff --git a/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md b/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md index 04e8e2cf2..8407d15cb 100644 --- a/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md +++ b/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md @@ -70,12 +70,14 @@ | 2025-11-22 | Restore attempt with absolute cache + nuget.org fallback (`NUGET_PACKAGES=/mnt/e/dev/git.stella-ops.org/local-nugets --source local-nugets --source https://api.nuget.org/v3/index.json`) still stalled/cancelled after ~10s; no packages pulled. | Implementer | | 2025-11-22 | Solution-filter restore (`concelier-webservice.slnf`, nuget.org only, absolute cache, minimal verbosity) stalled ~30s with no packages; blocked until CI runner with seeded cache is available. | Implementer | | 2025-11-22 | Tried timeout-limited restore via `dotnet restore concelier-webservice.slnf -v minimal`; cancelled around 25s (`NuGet.targets` reported "Restore canceled!"). Still no packages fetched—attestation test remains pending a CI/warmed cache runner. | Implementer | +| 2025-11-22 | Captured diagnostic restore attempt (`dotnet restore concelier-webservice.slnf -v diag` with 60s timeout); run was aborted after extended spinner with no packages downloaded and no new log produced. Attestation test remains blocked pending CI/warm cache. | Implementer | | 2025-11-22 | Normalized `tools/linksets-ci.sh` line endings, removed `--no-build`, and forced offline restore against `local-nugets`; restore still hangs >90s even with offline cache, run terminated. BUILD-TOOLING-110-001 remains BLOCKED pending runner with usable restore cache. | Implementer | | 2025-11-22 | Tried seeding `local-nugets` via `dotnet restore --packages local-nugets` (online allowed); restore spinner stalled ~130s and was cancelled; NuGet targets reported “Restore canceled!”. No TRX produced; BUILD-TOOLING-110-001 still BLOCKED—needs CI runner with warm cache or diagnostic restore to pinpoint stuck feed/package. | Implementer | | 2025-11-22 | Retried restore with dedicated cache `NUGET_PACKAGES=.nuget-cache`, sources `local-nugets` + nuget.org, `--disable-parallel --ignore-failed-sources`; spinner ran ~10s with no progress, cancelled. Still no TRX; BUILD-TOOLING-110-001 remains BLOCKED pending CI runner or verbose restore on cached agent. | Implementer | | 2025-11-22 | Another restore attempt with `NUGET_PACKAGES=.nuget-cache` and both sources enabled ran ~19s then was cancelled (`NuGet.targets` reported "Restore canceled!"); no packages downloaded, no TRX. BUILD-TOOLING-110-001 remains BLOCKED; next step is CI runner with warm cache or `-v diag` capture to identify the stuck feed/package. | Implementer | | 2025-11-22 | Captured 20s diagnostic restore log at `out/restore-log/linksets-restore-2025-11-22.log` (no HTTP requests observed before timeout). Restore still stalls pre-fetch; suggests resolver/startup hang. BUILD-TOOLING-110-001 remains BLOCKED pending CI runner with warm cache or longer `-v diag` on capable agent. | Implementer | | 2025-11-22 | Ran 60s diag restore with `DOTNET_SKIP_WORKLOAD_INVENTORY=1`, `--disable-parallel`; log at `out/restore-log/linksets-restore-2025-11-22-60s.log` shows no outbound HTTP before timeout (stall occurs during MSBuild evaluation). Still BLOCKED; needs CI agent with warm cache or deeper MSBuild tracing. | Implementer | +| 2025-11-22 | Attempted 60s restore with binary log (`/bl`) to capture MSBuild stall; run hung and harness aborted before binlog was written. Still BLOCKED locally; action remains to execute on CI runner with warm cache and capture full `/bl` output. | Implementer | | 2025-11-22 | Documented Concelier advisory attestation endpoint parameters and safety rules (`docs/modules/concelier/attestation.md`); linked from module architecture. | Implementer | | 2025-11-22 | Published Excititor air-gap + connector trust prep (`docs/modules/excititor/prep/2025-11-22-airgap-56-58-prep.md`), defining import envelope, error catalog, timeline hooks, and signer validation; marked EXCITITOR-AIRGAP-56/57/58 · CONN-TRUST-01-001 DONE. | Implementer | | 2025-11-20 | Completed PREP-FEEDCONN-ICSCISA-02-012-KISA-02-008-FEED: published remediation schedule + hashes at `docs/modules/concelier/prep/2025-11-20-feeds-icscisa-kisa-prep.md`; status set to DONE. | Implementer | diff --git a/docs/implplan/SPRINT_0113_0001_0002_concelier_ii.md b/docs/implplan/SPRINT_0113_0001_0002_concelier_ii.md index 1445be353..1a482f549 100644 --- a/docs/implplan/SPRINT_0113_0001_0002_concelier_ii.md +++ b/docs/implplan/SPRINT_0113_0001_0002_concelier_ii.md @@ -56,6 +56,7 @@ | 2025-11-22 | Added conflict sourceIds propagation to storage documents and mapping; updated storage tests accordingly. `dotnet test ...Concelier.Storage.Mongo.Tests` still fails locally with same vstest argument issue; needs CI runner. | Concelier Core | | 2025-11-22 | Tried `dotnet build src/Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj`; build appears to hang after restore on local harness—no errors emitted; will defer to CI runner to avoid churn. | Concelier Core | | 2025-11-22 | Local `dotnet build` for Storage.Mongo also hangs post-restore; CI/clean runner required to validate LNM-21-002 changes. | Concelier Core | +| 2025-11-22 | Ran `dotnet restore --source local-nugets` then `dotnet build ...Concelier.Core.csproj --no-restore`; Core now builds locally from `local-nugets`, but test runner remains blocked. | Concelier Core | | 2025-11-22 | Added `tools/run-concelier-linkset-tests.sh` to run targeted Core + Storage linkset tests with TRX output; pending CI execution to bypass local vstest harness issues. | Concelier Core | | 2025-11-22 | Fixed nullable handling in `LinksetCorrelation` purl aggregation; built Concelier dependencies and ran `AdvisoryObservationTransportWorkerTests` (pass) on warmed cache. | Implementer | | 2025-11-22 | Marked CONCELIER-LNM-21-002 DONE: correlation now emits confidence/conflicts deterministically; transport worker test green after nullable fixes and immutable summaries. | Implementer | @@ -79,6 +80,7 @@ | 2025-11-18 | LNM v1 frozen but fixtures + precedence rules still pending; CONCELIER-LNM-21-002 set to BLOCKED until inputs arrive. | Concelier Core | | 2025-11-17 | Documented optional `confidence`/`conflicts` fields in LNM linkset schema and refreshed sample payload. | Concelier Core | | 2025-11-18 | Core library build now succeeds post schema updates; Core.Tests build outputs still missing DLL locally—test execution deferred to CI/warmed runner while continuing implementation. | Concelier Core | +| 2025-11-22 | Restored Concelier Core/Storage via `dotnet restore --source local-nugets` and confirmed `dotnet build ...Concelier.Core.csproj` and `...Storage.Mongo.csproj` succeed locally; vstest still blocks test execution. | Concelier Core | ## Decisions & Risks - Link-Not-Merge v1 frozen 2025-11-17; schema captured in `docs/modules/concelier/link-not-merge-schema.md` (add-only evolution); fixtures pending for tasks 1–2, 5–15. diff --git a/docs/implplan/SPRINT_0114_0001_0003_concelier_iii.md b/docs/implplan/SPRINT_0114_0001_0003_concelier_iii.md index decd0cc70..167790dbf 100644 --- a/docs/implplan/SPRINT_0114_0001_0003_concelier_iii.md +++ b/docs/implplan/SPRINT_0114_0001_0003_concelier_iii.md @@ -38,7 +38,7 @@ | 11 | CONCELIER-ORCH-32-002 | BLOCKED (2025-11-22) | Blocked on 32-001 build validation; needs CI runner. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Adopt orchestrator worker SDK in ingestion loops; emit heartbeats/progress/artifact hashes for deterministic replays. | | 12 | CONCELIER-ORCH-33-001 | BLOCKED (2025-11-22) | Blocked on 32-001/002 build validation; needs CI runner. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Honor orchestrator pause/throttle/retry controls with structured errors and persisted checkpoints. | | 13 | CONCELIER-ORCH-34-001 | BLOCKED (2025-11-22) | Blocked on 32-001/002 build validation; needs CI runner. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Execute orchestrator-driven backfills reusing artifact hashes/signatures, logging provenance, and pushing run metadata to ledger. | -| 14 | CONCELIER-POLICY-20-001 | BLOCKED (2025-11-22) | OpenAPI source/spec missing in repo; needs canonical Concelier OAS location before exposure. | Concelier WebService Guild (`src/Concelier/StellaOps.Concelier.WebService`) | Provide batch advisory lookup APIs for Policy Engine (purl/advisory filters, tenant scopes, explain metadata) so policy joins raw evidence without inferred outcomes. | +| 14 | CONCELIER-POLICY-20-001 | DOING (2025-11-23) | OpenAPI source drafted at `src/Concelier/StellaOps.Concelier.WebService/openapi/concelier-lnm.yaml` (published copy: `docs/api/concelier/concelier-lnm.yaml`); list/search/get endpoints exposed, field coverage still partial (no severity/timeline). | Concelier WebService Guild (`src/Concelier/StellaOps.Concelier.WebService`) | Provide batch advisory lookup APIs for Policy Engine (purl/advisory filters, tenant scopes, explain metadata) so policy joins raw evidence without inferred outcomes. | ## Execution Log | Date (UTC) | Update | Owner | @@ -59,6 +59,9 @@ | 2025-11-22 | Marked all PREP tasks to DONE per directive; evidence to be verified. | Project Mgmt | | 2025-11-22 | Started Sprint 0114: set ORCH-32/33/34 chain to DOING, kept POLICY-20-001 BLOCKED pending canonical OpenAPI source; refreshed blockers accordingly. | Project Mgmt | | 2025-11-22 | Added blocker entry for missing Concelier OpenAPI source to keep POLICY-20-001 flagged until canonical spec location exists. | Project Mgmt | +| 2025-11-23 | Added Link-Not-Merge Policy OpenAPI source (`src/Concelier/StellaOps.Concelier.WebService/openapi/concelier-lnm.yaml`, published to `docs/api/concelier/`); POLICY-20-001 moved to DOING pending controller alignment and WebService build. | Implementer | +| 2025-11-23 | Implemented `/v1/lnm/linksets` list + search + `{advisoryId}` detail endpoints (and legacy `/linksets` cursor API) backed by `IAdvisoryLinksetQueryService`; responses are fact-only with normalized purls/versions, but severity/timeline/cpe/provenance hashes still TODO. | Implementer | +| 2025-11-23 | Updated `concelier-lnm.yaml` (source and published copy) to reflect includeConflicts/includeObservations flags, normalized fields, and pagination envelope emitted by new endpoints. | Implementer | | 2025-11-22 | Updated `src/Concelier/AGENTS.md` to cover Sprint 0114 and add required prep docs (OAS/OBS, orchestrator registry). | Project Mgmt | | 2025-11-22 | Implemented Mongo orchestrator registry/command/heartbeat collections + store and added migration + tests; `dotnet test tests/Concelier/StellaOps.Concelier.Storage.Mongo.Tests/StellaOps.Concelier.Storage.Mongo.Tests.csproj --no-build` passes. | Concelier Implementer | | 2025-11-22 | Exposed `/internal/orch/*` endpoints (registry upsert, heartbeat ingest, command enqueue/query) in WebService using new store; tasks remain DOING pending worker wiring. | Concelier Implementer | @@ -66,14 +69,17 @@ | 2025-11-22 | WebService build attempt (`dotnet build ...WebService.csproj --no-restore`) failed on pre-existing nullability errors in `LinksetCorrelation.cs`; no new errors from orchestrator endpoints. | Concelier Implementer | | 2025-11-22 | Reworked `LinksetCorrelation` nullability to unblock build; lingering CS8620 persists after clean rebuild—likely upstream nullable config; needs follow-up. | Concelier Implementer | | 2025-11-22 | Package cache cleaned; `dotnet build ...WebService.csproj --no-restore` now fails on missing local packages (Polly, IdentityModel, etc.); restore from `local-nugets/` required to re-run compile. | Concelier Implementer | +| 2025-11-22 | Restored packages from `local-nugets`; WebService build still blocked by CS8620 in `LinksetCorrelation.cs` (HashSet inference). Further nullable tightening needed. | Concelier Implementer | | 2025-11-22 | Marked ORCH-32/33/34 BLOCKED pending CI/clean runner build + restore (local runner stuck on missing packages/nullability). | Concelier Core | | 2025-11-22 | Retried `dotnet restore concelier-webservice.slnf -v minimal` with timeout guard; cancelled at ~25s with `NuGet.targets` reporting "Restore canceled!". No packages downloaded; ORCH-32/33/34 remain blocked until CI/warm cache is available. | Concelier Implementer | +| 2025-11-22 | Ran `dotnet restore concelier-webservice.slnf -v diag` (60s timeout); aborted after prolonged spinner, no packages fetched, no new diagnostic log produced. Orchestrator tasks stay blocked pending CI/runner with warm cache. | Concelier Implementer | ## Decisions & Risks - Link-Not-Merge and OpenAPI alignment must precede SDK/examples; otherwise downstream clients will drift from canonical facts. - Observability/attestation chain (OBS-51…55) risks audit gaps if sequencing slips; each step depends on previous artifacts. - Orchestrator control compliance is required to prevent evidence loss during throttles/pauses. -- OpenAPI source (swagger/OAS) for Concelier endpoints is missing from the repo; OAS tasks 61-001..63-001 (and dependent Policy 20-001 tasks) cannot proceed until the canonical spec artifact is provided or generated location is identified. +- OpenAPI source (swagger/OAS) for Concelier endpoints now exists; downstream SDK tasks must align with `openapi/concelier-lnm.yaml` to avoid drift. +- LNM linkset endpoints currently omit severity/published/modified timeline fields and provenance hashes; Policy consumers may need these before marking CONCELIER-POLICY-20-001 DONE. Follow-up required to enrich payloads without violating AOC. - Observability metric/attestation contracts are absent; OBS tasks 51-001..55-001 cannot proceed without metric names/labels, AOC thresholds, and timeline/attestation schemas. - Orchestrator registry/SDK contract now documented (see prep note above); downstream tasks must keep in sync with orchestrator module changes. - Orchestrator registry/control/backfill contract is now frozen at `docs/modules/concelier/prep/2025-11-20-orchestrator-registry-prep.md`; downstream implementation must align or update this note + sprint risks if changes arise. diff --git a/docs/implplan/SPRINT_0115_0001_0004_concelier_iv.md b/docs/implplan/SPRINT_0115_0001_0004_concelier_iv.md index 7172b860a..c6822343e 100644 --- a/docs/implplan/SPRINT_0115_0001_0004_concelier_iv.md +++ b/docs/implplan/SPRINT_0115_0001_0004_concelier_iv.md @@ -26,9 +26,9 @@ | P3 | PREP-CONCELIER-VULN-29-001 | DONE (2025-11-19) | Bridge contract published at `docs/modules/concelier/bridges/vuln-29-001.md`; sample fixture location noted. | Concelier WebService Guild · Vuln Explorer Guild (`src/Concelier/StellaOps.Concelier.WebService`) | Provide Concelier/Vuln bridge contract (advisory keys, search params, sample responses) that VEX Lens + Vuln Explorer rely on; publish OpenAPI excerpt and fixtures. | | 0 | POLICY-AUTH-SIGNALS-LIB-115 | DONE (2025-11-19) | Package `StellaOps.Policy.AuthSignals` 0.1.0-alpha published to `local-nugets/`; schema/fixtures at `docs/policy/*`. | Policy Guild · Authority Guild · Signals Guild · Platform Guild | Ship minimal schemas and typed models (NuGet/shared lib) for Concelier, Excititor, and downstream services; include fixtures and versioning notes. | | 1 | CONCELIER-POLICY-20-002 | DONE (2025-11-20) | Vendor alias + SemVer range normalization landed; tests green. | Concelier Core Guild · Policy Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Expand linkset builders with vendor equivalence, NEVRA/PURL normalization, version-range parsing so policy joins are accurate without prioritizing sources. | -| 2 | CONCELIER-POLICY-20-003 | TODO | Start after 20-002. | Concelier Storage Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo`) | Advisory selection cursors + change-stream checkpoints for deterministic policy deltas; include offline migration scripts. | -| 3 | CONCELIER-POLICY-23-001 | TODO | Start after 20-003. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Secondary indexes/materialized views (alias, provider severity, confidence) to keep policy lookups fast without cached verdicts; document query patterns. | -| 4 | CONCELIER-POLICY-23-002 | TODO | Start after 23-001. | Concelier Core Guild · Platform Events Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Ensure `advisory.linkset.updated` events carry idempotent IDs, confidence summaries, tenant metadata for safe policy replay. | +| 2 | CONCELIER-POLICY-20-003 | BLOCKED | Upstream POLICY-20-001 outputs missing; 20-002 complete. | Concelier Storage Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Storage.Mongo`) | Advisory selection cursors + change-stream checkpoints for deterministic policy deltas; include offline migration scripts. | +| 3 | CONCELIER-POLICY-23-001 | BLOCKED | Depends on 20-003 (blocked). | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Secondary indexes/materialized views (alias, provider severity, confidence) to keep policy lookups fast without cached verdicts; document query patterns. | +| 4 | CONCELIER-POLICY-23-002 | BLOCKED | Depends on 23-001 (blocked). | Concelier Core Guild · Platform Events Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Ensure `advisory.linkset.updated` events carry idempotent IDs, confidence summaries, tenant metadata for safe policy replay. | | 5 | CONCELIER-RISK-66-001 | BLOCKED | Blocked on POLICY-AUTH-SIGNALS-LIB-115 and POLICY chain. | Concelier Core Guild · Risk Engine Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Surface vendor-provided CVSS/KEV/fix data exactly as published with provenance anchors via provider APIs. | | 6 | CONCELIER-RISK-66-002 | BLOCKED | Blocked on POLICY-AUTH-SIGNALS-LIB-115 and 66-001. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Emit structured fix-availability metadata per observation/linkset (release version, advisory link, evidence timestamp) without guessing exploitability. | | 7 | CONCELIER-RISK-67-001 | BLOCKED | Blocked on POLICY-AUTH-SIGNALS-LIB-115 and 66-001. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Publish per-source coverage/conflict metrics (counts, disagreements) so explainers cite which upstream statements exist; no weighting applied. | @@ -60,6 +60,7 @@ | 2025-11-19 | POLICY-AUTH-SIGNALS-LIB-115 remains BLOCKED awaiting package publish/ratification; added upstream contracts (AUTH-TEN-47-001, CONCELIER-VULN-29-001) to unblock downstream tasks once library ships. | Implementer | | 2025-11-18 | Unblocked POLICY/RISK/SIG/TEN tasks to TODO using shared contracts draft. | Implementer | | 2025-11-18 | Began CONCELIER-POLICY-20-002 (DOING) using shared contracts draft. | Implementer | +| 2025-11-22 | Marked CONCELIER-POLICY-20-003/23-001/23-002 BLOCKED due to missing upstream POLICY-20-001 outputs and stalled Core test harness; awaiting CI-run validation and policy schema sign-off. | Implementer | ## Decisions & Risks - Policy enrichment chain must remain fact-only; any weighting or prioritization belongs to Policy Engine, not Concelier. diff --git a/out/tools/StellaOps.Provenance.Attestation.Tool.1.0.0.nupkg b/out/tools/StellaOps.Provenance.Attestation.Tool.1.0.0.nupkg new file mode 100644 index 000000000..af9d7f4c5 Binary files /dev/null and b/out/tools/StellaOps.Provenance.Attestation.Tool.1.0.0.nupkg differ diff --git a/src/Concelier/StellaOps.Concelier.WebService/Contracts/LnmLinksetContracts.cs b/src/Concelier/StellaOps.Concelier.WebService/Contracts/LnmLinksetContracts.cs index 8148fa8c8..06b39e81a 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Contracts/LnmLinksetContracts.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Contracts/LnmLinksetContracts.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; @@ -9,16 +8,30 @@ namespace StellaOps.Concelier.WebService.Contracts; public sealed record LnmLinksetResponse( [property: JsonPropertyName("advisoryId")] string AdvisoryId, [property: JsonPropertyName("source")] string Source, - [property: JsonPropertyName("observations")] IReadOnlyList Observations, - [property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized, - [property: JsonPropertyName("conflicts")] IReadOnlyList? Conflicts, + [property: JsonPropertyName("purl")] IReadOnlyList Purl, + [property: JsonPropertyName("cpe")] IReadOnlyList Cpe, + [property: JsonPropertyName("summary")] string? Summary, + [property: JsonPropertyName("publishedAt")] DateTimeOffset? PublishedAt, + [property: JsonPropertyName("modifiedAt")] DateTimeOffset? ModifiedAt, + [property: JsonPropertyName("severity")] string? Severity, + [property: JsonPropertyName("status")] string? Status, [property: JsonPropertyName("provenance")] LnmLinksetProvenance? Provenance, - [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt, - [property: JsonPropertyName("builtByJobId")] string? BuiltByJobId, - [property: JsonPropertyName("cached")] bool Cached); + [property: JsonPropertyName("conflicts")] IReadOnlyList Conflicts, + [property: JsonPropertyName("timeline")] IReadOnlyList Timeline, + [property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized, + [property: JsonPropertyName("cached")] bool Cached, + [property: JsonPropertyName("remarks")] IReadOnlyList Remarks, + [property: JsonPropertyName("observations")] IReadOnlyList Observations); + +public sealed record LnmLinksetPage( + [property: JsonPropertyName("items")] IReadOnlyList Items, + [property: JsonPropertyName("page")] int Page, + [property: JsonPropertyName("pageSize")] int PageSize, + [property: JsonPropertyName("total")] int? Total); public sealed record LnmLinksetNormalized( - [property: JsonPropertyName("purls")] IReadOnlyList? Purls, + [property: JsonPropertyName("aliases")] IReadOnlyList? Aliases, + [property: JsonPropertyName("purl")] IReadOnlyList? Purl, [property: JsonPropertyName("versions")] IReadOnlyList? Versions, [property: JsonPropertyName("ranges")] IReadOnlyList? Ranges, [property: JsonPropertyName("severities")] IReadOnlyList? Severities); @@ -26,16 +39,41 @@ public sealed record LnmLinksetNormalized( public sealed record LnmLinksetConflict( [property: JsonPropertyName("field")] string Field, [property: JsonPropertyName("reason")] string Reason, - [property: JsonPropertyName("values")] IReadOnlyList? Values); + [property: JsonPropertyName("observedValue")] string? ObservedValue, + [property: JsonPropertyName("observedAt")] DateTimeOffset? ObservedAt, + [property: JsonPropertyName("evidenceHash")] string? EvidenceHash); + +public sealed record LnmLinksetTimeline( + [property: JsonPropertyName("event")] string Event, + [property: JsonPropertyName("at")] DateTimeOffset? At, + [property: JsonPropertyName("evidenceHash")] string? EvidenceHash); public sealed record LnmLinksetProvenance( - [property: JsonPropertyName("observationHashes")] IReadOnlyList? ObservationHashes, - [property: JsonPropertyName("toolVersion")] string? ToolVersion, - [property: JsonPropertyName("policyHash")] string? PolicyHash); + [property: JsonPropertyName("ingestedAt")] DateTimeOffset? IngestedAt, + [property: JsonPropertyName("connectorId")] string? ConnectorId, + [property: JsonPropertyName("evidenceHash")] string? EvidenceHash, + [property: JsonPropertyName("dsseEnvelopeHash")] string? DsseEnvelopeHash); public sealed record LnmLinksetQuery( [Required] [property: JsonPropertyName("advisoryId")] string AdvisoryId, - [Required] - [property: JsonPropertyName("source")] string Source, - [property: JsonPropertyName("includeConflicts")] bool IncludeConflicts = true); + [property: JsonPropertyName("source")] string? Source = null, + [property: JsonPropertyName("includeConflicts")] bool IncludeConflicts = true, + [property: JsonPropertyName("includeObservations")] bool IncludeObservations = false); + +public sealed record LnmLinksetSearchRequest( + [property: JsonPropertyName("purl")] IReadOnlyList? Purl, + [property: JsonPropertyName("cpe")] IReadOnlyList? Cpe, + [property: JsonPropertyName("ghsa")] string? Ghsa, + [property: JsonPropertyName("cve")] string? Cve, + [property: JsonPropertyName("advisoryId")] string? AdvisoryId, + [property: JsonPropertyName("source")] string? Source, + [property: JsonPropertyName("severityMin")] double? SeverityMin, + [property: JsonPropertyName("severityMax")] double? SeverityMax, + [property: JsonPropertyName("publishedSince")] DateTimeOffset? PublishedSince, + [property: JsonPropertyName("modifiedSince")] DateTimeOffset? ModifiedSince, + [property: JsonPropertyName("includeTimeline")] bool IncludeTimeline = false, + [property: JsonPropertyName("includeObservations")] bool IncludeObservations = false, + [property: JsonPropertyName("page")] int? Page = null, + [property: JsonPropertyName("pageSize")] int? PageSize = null, + [property: JsonPropertyName("sort")] string? Sort = null); diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index ace36a12a..096991e68 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -118,6 +118,7 @@ builder.Services.AddOptions() .ValidateOnStart(); builder.Services.AddConcelierAocGuards(); builder.Services.AddConcelierLinksetMappers(); +builder.Services.TryAddSingleton(); builder.Services.AddSingleton(MeterProvider.Default.GetMeterProvider()); builder.Services.AddSingleton(); builder.Services.AddAdvisoryRawServices(); @@ -619,14 +620,17 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async ( return Results.Ok(response); }).WithName("GetConcelierObservations"); -app.MapGet("/v1/lnm/linksets/{advisoryId}", async ( +const int DefaultLnmPageSize = 50; +const int MaxLnmPageSize = 200; + +app.MapGet("/v1/lnm/linksets", async ( HttpContext context, - string advisoryId, - [FromQuery(Name = "source")] string source, - [FromQuery(Name = "includeConflicts")] bool includeConflicts, - [FromServices] IAdvisoryLinksetLookup linksetLookup, - [FromServices] LinksetCacheTelemetry telemetry, - TimeProvider timeProvider, + [FromQuery(Name = "advisoryId")] string? advisoryId, + [FromQuery(Name = "source")] string? source, + [FromQuery(Name = "page")] int? page, + [FromQuery(Name = "pageSize")] int? pageSize, + [FromQuery(Name = "includeConflicts")] bool? includeConflicts, + [FromServices] IAdvisoryLinksetQueryService queryService, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -642,36 +646,116 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async ( return authorizationError; } - if (string.IsNullOrWhiteSpace(advisoryId) || string.IsNullOrWhiteSpace(source)) + var resolvedPage = NormalizePage(page); + var resolvedPageSize = NormalizePageSize(pageSize); + + var advisoryIds = string.IsNullOrWhiteSpace(advisoryId) ? null : new[] { advisoryId.Trim() }; + var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() }; + + var result = await QueryPageAsync( + queryService, + tenant!, + advisoryIds, + sources, + resolvedPage, + resolvedPageSize, + cancellationToken).ConfigureAwait(false); + + var items = result.Items + .Select(linkset => ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false)) + .ToArray(); + + return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total)); +}).WithName("ListLnmLinksets"); + +app.MapPost("/v1/lnm/linksets/search", async ( + HttpContext context, + [FromBody] LnmLinksetSearchRequest request, + [FromServices] IAdvisoryLinksetQueryService queryService, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) { - return Results.BadRequest("advisoryId and source are required."); + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + var resolvedPage = NormalizePage(request.Page); + var resolvedPageSize = NormalizePageSize(request.PageSize); + + var advisoryIds = string.IsNullOrWhiteSpace(request.AdvisoryId) ? null : new[] { request.AdvisoryId.Trim() }; + var sources = string.IsNullOrWhiteSpace(request.Source) ? null : new[] { request.Source.Trim() }; + + var result = await QueryPageAsync( + queryService, + tenant!, + advisoryIds, + sources, + resolvedPage, + resolvedPageSize, + cancellationToken).ConfigureAwait(false); + + var items = result.Items + .Select(linkset => ToLnmResponse( + linkset, + includeConflicts: true, + includeTimeline: request.IncludeTimeline, + includeObservations: request.IncludeObservations)) + .ToArray(); + + return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total)); +}).WithName("SearchLnmLinksets"); + +app.MapGet("/v1/lnm/linksets/{advisoryId}", async ( + HttpContext context, + string advisoryId, + [FromQuery(Name = "source")] string? source, + [FromQuery(Name = "includeConflicts")] bool includeConflicts = true, + [FromQuery(Name = "includeObservations")] bool includeObservations = false, + [FromServices] IAdvisoryLinksetQueryService queryService, + [FromServices] LinksetCacheTelemetry telemetry, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + if (string.IsNullOrWhiteSpace(advisoryId)) + { + return Results.BadRequest("advisoryId is required."); } var stopwatch = Stopwatch.StartNew(); + var advisoryIds = new[] { advisoryId.Trim() }; + var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() }; - var options = new AdvisoryLinksetQueryOptions(tenant!, Source: source.Trim(), AdvisoryId: advisoryId.Trim()); - var linksets = await linksetLookup.FindByTenantAsync(options.TenantId, options.Source, options.AdvisoryId, cancellationToken).ConfigureAwait(false); + var result = await queryService + .QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, limit: 1), cancellationToken) + .ConfigureAwait(false); - if (linksets.Count == 0) + if (result.Linksets.IsDefaultOrEmpty) { return Results.NotFound(); } - var linkset = linksets[0]; - var response = new LnmLinksetResponse( - linkset.AdvisoryId, - linkset.Source, - linkset.Observations, - linkset.Normalized is null - ? null - : new LnmLinksetNormalized(linkset.Normalized.Purls, linkset.Normalized.Versions, linkset.Normalized.Ranges, linkset.Normalized.Severities), - includeConflicts ? linkset.Conflicts : Array.Empty(), - linkset.Provenance is null - ? null - : new LnmLinksetProvenance(linkset.Provenance.ObservationHashes, linkset.Provenance.ToolVersion, linkset.Provenance.PolicyHash), - linkset.CreatedAt, - linkset.BuiltByJobId, - Cached: true); + var linkset = result.Linksets[0]; + var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations); telemetry.RecordHit(tenant, linkset.Source); telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds); @@ -679,6 +763,45 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async ( return Results.Ok(response); }).WithName("GetLnmLinkset"); +app.MapGet("/linksets", async ( + HttpContext context, + [FromQuery(Name = "limit")] int? limit, + [FromQuery(Name = "cursor")] string? cursor, + [FromServices] IAdvisoryLinksetQueryService queryService, + CancellationToken cancellationToken) => +{ + ApplyNoCache(context.Response); + + if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError)) + { + return tenantError; + } + + var authorizationError = EnsureTenantAuthorized(context, tenant); + if (authorizationError is not null) + { + return authorizationError; + } + + var result = await queryService.QueryAsync( + new AdvisoryLinksetQueryOptions(tenant, Limit: limit, Cursor: cursor), + cancellationToken).ConfigureAwait(false); + + var payload = new + { + linksets = result.Linksets.Select(ls => new + { + AdvisoryId = ls.AdvisoryId, + Purls = ls.Normalized?.Purls ?? Array.Empty(), + Versions = ls.Normalized?.Versions ?? Array.Empty() + }), + hasMore = result.HasMore, + nextCursor = result.NextCursor + }; + + return Results.Ok(payload); +}).WithName("ListLinksetsLegacy"); + if (authorityConfigured) { observationsEndpoint.RequireAuthorization(ObservationsPolicyName); @@ -1453,6 +1576,120 @@ if (authorityConfigured) }); } +int NormalizePage(int? pageValue) +{ + if (!pageValue.HasValue || pageValue.Value <= 0) + { + return 1; + } + + return pageValue.Value; +} + +int NormalizePageSize(int? size) +{ + if (!size.HasValue || size.Value <= 0) + { + return DefaultLnmPageSize; + } + + return size.Value > MaxLnmPageSize ? MaxLnmPageSize : size.Value; +} + +async Task<(IReadOnlyList Items, int? Total)> QueryPageAsync( + IAdvisoryLinksetQueryService queryService, + string tenant, + IEnumerable? advisoryIds, + IEnumerable? sources, + int page, + int pageSize, + CancellationToken cancellationToken) +{ + var cursor = (string?)null; + AdvisoryLinksetQueryResult? result = null; + + for (var current = 1; current <= page; current++) + { + result = await queryService + .QueryAsync(new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, pageSize, cursor), cancellationToken) + .ConfigureAwait(false); + + if (!result.HasMore && current < page) + { + var exhaustedTotal = ((current - 1) * pageSize) + result.Linksets.Length; + return (Array.Empty(), exhaustedTotal); + } + + cursor = result.NextCursor; + } + + if (result is null) + { + return (Array.Empty(), 0); + } + + var total = result.HasMore ? null : (int?)(((page - 1) * pageSize) + result.Linksets.Length); + return (result.Linksets, total); +} + +LnmLinksetResponse ToLnmResponse( + AdvisoryLinkset linkset, + bool includeConflicts, + bool includeTimeline, + bool includeObservations) +{ + var normalized = linkset.Normalized; + var conflicts = includeConflicts + ? (linkset.Conflicts ?? Array.Empty()).Select(c => + new LnmLinksetConflict( + c.Field, + c.Reason, + c.Values is null ? null : string.Join(", ", c.Values), + ObservedAt: null, + EvidenceHash: c.SourceIds?.FirstOrDefault())) + .ToArray() + : Array.Empty(); + + var timeline = includeTimeline + ? Array.Empty() // timeline not yet captured in linkset store + : Array.Empty(); + + var provenance = linkset.Provenance is null + ? new LnmLinksetProvenance(linkset.CreatedAt, null, null, null) + : new LnmLinksetProvenance( + linkset.CreatedAt, + connectorId: null, + evidenceHash: linkset.Provenance.ObservationHashes?.FirstOrDefault(), + dsseEnvelopeHash: null); + + var normalizedDto = normalized is null + ? null + : new LnmLinksetNormalized( + Aliases: null, + Purl: normalized.Purls, + Versions: normalized.Versions, + Ranges: normalized.Ranges?.Select(r => (object)r).ToArray(), + Severities: normalized.Severities?.Select(s => (object)s).ToArray()); + + return new LnmLinksetResponse( + linkset.AdvisoryId, + linkset.Source, + normalized?.Purls ?? Array.Empty(), + Array.Empty(), + Summary: null, + PublishedAt: linkset.CreatedAt, + ModifiedAt: linkset.CreatedAt, + Severity: null, + Status: "fact-only", + provenance, + conflicts, + timeline, + normalizedDto, + Cached: false, + Remarks: Array.Empty(), + Observations: includeObservations ? linkset.ObservationIds : Array.Empty()); +} + IResult JsonResult(T value, int? statusCode = null) { var payload = JsonSerializer.Serialize(value, jsonOptions); diff --git a/src/Concelier/StellaOps.Concelier.WebService/openapi/concelier-lnm.yaml b/src/Concelier/StellaOps.Concelier.WebService/openapi/concelier-lnm.yaml new file mode 100644 index 000000000..104ab608c --- /dev/null +++ b/src/Concelier/StellaOps.Concelier.WebService/openapi/concelier-lnm.yaml @@ -0,0 +1,276 @@ +openapi: 3.1.0 +info: + title: StellaOps Concelier – Link-Not-Merge Policy APIs + version: "0.1.0" + description: Fact-only advisory/linkset retrieval for Policy Engine consumers. +servers: + - url: / + description: Relative base path (API Gateway rewrites in production). +tags: + - name: Linksets + description: Link-Not-Merge linkset retrieval +paths: + /v1/lnm/linksets: + get: + summary: List linksets + tags: [Linksets] + parameters: + - $ref: '#/components/parameters/Tenant' + - name: includeConflicts + in: query + required: false + schema: { type: boolean, default: true } + - name: includeObservations + in: query + required: false + schema: { type: boolean, default: false } + - $ref: '#/components/parameters/purl' + - $ref: '#/components/parameters/cpe' + - $ref: '#/components/parameters/ghsa' + - $ref: '#/components/parameters/cve' + - $ref: '#/components/parameters/advisoryId' + - $ref: '#/components/parameters/source' + - $ref: '#/components/parameters/severityMin' + - $ref: '#/components/parameters/severityMax' + - $ref: '#/components/parameters/publishedSince' + - $ref: '#/components/parameters/modifiedSince' + - $ref: '#/components/parameters/page' + - $ref: '#/components/parameters/pageSize' + - $ref: '#/components/parameters/sort' + responses: + "200": + description: Deterministically ordered list of linksets + content: + application/json: + schema: + $ref: '#/components/schemas/PagedLinksets' + /v1/lnm/linksets/{advisoryId}: + get: + summary: Get linkset by advisory ID + tags: [Linksets] + parameters: + - $ref: '#/components/parameters/Tenant' + - name: advisoryId + in: path + required: true + schema: + type: string + - name: source + in: query + required: false + schema: { type: string } + - name: includeConflicts + in: query + required: false + schema: { type: boolean, default: true } + - name: includeObservations + in: query + required: false + schema: { type: boolean, default: false } + responses: + "200": + description: Linkset with provenance and conflicts + content: + application/json: + schema: + $ref: '#/components/schemas/Linkset' + "404": + description: Not found + /v1/lnm/linksets/search: + post: + summary: Search linksets (body filters) + tags: [Linksets] + parameters: + - $ref: '#/components/parameters/Tenant' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LinksetSearchRequest' + responses: + "200": + description: Deterministically ordered search results + content: + application/json: + schema: + $ref: '#/components/schemas/PagedLinksets' +components: + parameters: + Tenant: + name: Tenant + in: header + required: true + schema: + type: string + description: Tenant identifier (required). + purl: + name: purl + in: query + schema: + type: array + items: { type: string } + style: form + explode: true + cpe: + name: cpe + in: query + schema: { type: string } + ghsa: + name: ghsa + in: query + schema: { type: string } + cve: + name: cve + in: query + schema: { type: string } + advisoryId: + name: advisoryId + in: query + schema: { type: string } + source: + name: source + in: query + schema: + type: string + severityMin: + name: severityMin + in: query + schema: + type: number + format: float + severityMax: + name: severityMax + in: query + schema: + type: number + format: float + publishedSince: + name: publishedSince + in: query + schema: + type: string + format: date-time + modifiedSince: + name: modifiedSince + in: query + schema: + type: string + format: date-time + page: + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + pageSize: + name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + sort: + name: sort + in: query + schema: + type: string + enum: + - modifiedAt desc + - modifiedAt asc + - publishedAt desc + - publishedAt asc + - severity desc + - severity asc + - source + - advisoryId + description: Default modifiedAt desc; ties advisoryId asc, source asc. + schemas: + LinksetSearchRequest: + type: object + properties: + purl: { type: array, items: { type: string } } + cpe: { type: array, items: { type: string } } + ghsa: { type: string } + cve: { type: string } + advisoryId: { type: string } + source: { type: string } + severityMin: { type: number } + severityMax: { type: number } + publishedSince: { type: string, format: date-time } + modifiedSince: { type: string, format: date-time } + includeTimeline: { type: boolean, default: false } + includeObservations: { type: boolean, default: false } + includeConflicts: { type: boolean, default: true } + page: { type: integer, minimum: 1, default: 1 } + pageSize: { type: integer, minimum: 1, maximum: 200, default: 50 } + sort: { type: string, enum: [modifiedAt desc, modifiedAt asc, publishedAt desc, publishedAt asc, severity desc, severity asc, source, advisoryId] } + PagedLinksets: + type: object + properties: + items: + type: array + items: { $ref: '#/components/schemas/Linkset' } + page: { type: integer } + pageSize: { type: integer } + total: { type: integer } + Linkset: + type: object + required: [advisoryId, source, purl, cpe, provenance] + properties: + advisoryId: { type: string } + source: { type: string } + purl: { type: array, items: { type: string } } + cpe: { type: array, items: { type: string } } + summary: { type: string } + publishedAt: { type: string, format: date-time } + modifiedAt: { type: string, format: date-time } + severity: { type: string, description: Source-native severity label } + status: { type: string } + provenance: { $ref: '#/components/schemas/LinksetProvenance' } + conflicts: + type: array + items: { $ref: '#/components/schemas/LinksetConflict' } + timeline: + type: array + items: { $ref: '#/components/schemas/LinksetTimeline' } + normalized: + type: object + properties: + aliases: { type: array, items: { type: string } } + purl: { type: array, items: { type: string } } + versions: { type: array, items: { type: string } } + ranges: { type: array, items: { type: object } } + severities: { type: array, items: { type: object } } + cached: + type: boolean + description: True if served from cache; provenance.evidenceHash present for integrity. + remarks: + type: array + items: { type: string } + observations: + type: array + items: { type: string } + LinksetProvenance: + type: object + properties: + ingestedAt: { type: string, format: date-time } + connectorId: { type: string } + evidenceHash: { type: string } + dsseEnvelopeHash: { type: string } + LinksetConflict: + type: object + properties: + field: { type: string } + reason: { type: string } + observedValue: { type: string } + observedAt: { type: string, format: date-time } + evidenceHash: { type: string } + LinksetTimeline: + type: object + properties: + event: { type: string } + at: { type: string, format: date-time } + evidenceHash: { type: string } diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Linksets/LinksetCorrelation.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Linksets/LinksetCorrelation.cs index 8886aef8d..130ddbc1e 100644 --- a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Linksets/LinksetCorrelation.cs +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Linksets/LinksetCorrelation.cs @@ -110,7 +110,9 @@ internal static class LinksetCorrelation .Select(i => i.Purls .Select(ExtractPackageKey) .Where(k => !string.IsNullOrWhiteSpace(k)) + .Select(k => k!) .ToHashSet(StringComparer.Ordinal)) + .Select(set => new HashSet(set, StringComparer.Ordinal)) .ToList(); var seed = packageKeysPerInput.FirstOrDefault() ?? new HashSet(StringComparer.Ordinal); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs index 702fa6446..19671929e 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/WebServiceEndpointsTests.cs @@ -311,6 +311,54 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime Assert.True(string.IsNullOrEmpty(secondPayload.NextCursor)); } + [Fact] + public async Task LnmLinksetsEndpoints_ReturnFactOnlyLinksets() + { + var tenant = "tenant-lnm-list"; + var documents = new[] + { + CreateLinksetDocument( + tenant, + "nvd", + "ADV-002", + new[] { "obs-2" }, + new[] { "pkg:npm/demo@2.0.0" }, + new[] { "2.0.0" }, + new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc)), + CreateLinksetDocument( + tenant, + "osv", + "ADV-001", + new[] { "obs-1" }, + new[] { "pkg:npm/demo@1.0.0" }, + new[] { "1.0.0" }, + new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc)) + }; + + await SeedLinksetDocumentsAsync(documents); + + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant); + + var listResponse = await client.GetAsync("/v1/lnm/linksets?pageSize=1&page=1"); + listResponse.EnsureSuccessStatusCode(); + + var listPayload = await listResponse.Content.ReadFromJsonAsync(); + var firstItem = listPayload.GetProperty("items").EnumerateArray().First(); + Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString()); + Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString())); + Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0); + + var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true"); + detailResponse.EnsureSuccessStatusCode(); + + var detailPayload = await detailResponse.Content.ReadFromJsonAsync(); + Assert.Equal("ADV-001", detailPayload.GetProperty("advisoryId").GetString()); + Assert.Equal("osv", detailPayload.GetProperty("source").GetString()); + Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString())); + Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString())); + } + [Fact] public async Task ObservationsEndpoint_ReturnsBadRequestWhenTenantMissing() { diff --git a/src/Provenance/StellaOps.Provenance.Attestation.Tool/StellaOps.Provenance.Attestation.Tool.csproj b/src/Provenance/StellaOps.Provenance.Attestation.Tool/StellaOps.Provenance.Attestation.Tool.csproj index 6f81e5700..e227d4eab 100644 --- a/src/Provenance/StellaOps.Provenance.Attestation.Tool/StellaOps.Provenance.Attestation.Tool.csproj +++ b/src/Provenance/StellaOps.Provenance.Attestation.Tool/StellaOps.Provenance.Attestation.Tool.csproj @@ -4,6 +4,7 @@ preview enable enable + Exe true stella-forensic-verify ../../out/tools diff --git a/src/Provenance/StellaOps.Provenance.Attestation/Verification.cs b/src/Provenance/StellaOps.Provenance.Attestation/Verification.cs index 5e7c8b95e..348c4fa9e 100644 --- a/src/Provenance/StellaOps.Provenance.Attestation/Verification.cs +++ b/src/Provenance/StellaOps.Provenance.Attestation/Verification.cs @@ -60,7 +60,7 @@ public static class MerkleRootVerifier { var provider = timeProvider ?? TimeProvider.System; if (leaves is null) throw new ArgumentNullException(nameof(leaves)); - expectedRoot ??= throw new ArgumentNullException(nameof(expectedRoot)); + if (expectedRoot is null) throw new ArgumentNullException(nameof(expectedRoot)); var leafList = leaves.ToList(); var computed = MerkleTree.ComputeRoot(leafList); @@ -79,7 +79,7 @@ public static class ChainOfCustodyVerifier { var provider = timeProvider ?? TimeProvider.System; if (hops is null) throw new ArgumentNullException(nameof(hops)); - expectedHead ??= throw new ArgumentNullException(nameof(expectedHead)); + if (expectedHead is null) throw new ArgumentNullException(nameof(expectedHead)); var list = hops.ToList(); if (list.Count == 0)