Add OpenAPI specification for Link-Not-Merge Policy APIs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Introduced a new OpenAPI YAML file for the StellaOps Concelier service. - Defined endpoints for listing linksets, retrieving linksets by advisory ID, and searching linksets. - Included detailed parameter specifications and response schemas for each endpoint. - Established components for reusable parameters and schemas, enhancing API documentation clarity.
This commit is contained in:
276
docs/api/concelier/concelier-lnm.yaml
Normal file
276
docs/api/concelier/concelier-lnm.yaml
Normal file
@@ -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 }
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string?> 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
out/tools/StellaOps.Provenance.Attestation.Tool.1.0.0.nupkg
Normal file
BIN
out/tools/StellaOps.Provenance.Attestation.Tool.1.0.0.nupkg
Normal file
Binary file not shown.
@@ -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<string> Observations,
|
||||
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<LnmLinksetConflict>? Conflicts,
|
||||
[property: JsonPropertyName("purl")] IReadOnlyList<string> Purl,
|
||||
[property: JsonPropertyName("cpe")] IReadOnlyList<string> 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<LnmLinksetConflict> Conflicts,
|
||||
[property: JsonPropertyName("timeline")] IReadOnlyList<LnmLinksetTimeline> Timeline,
|
||||
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
|
||||
[property: JsonPropertyName("cached")] bool Cached,
|
||||
[property: JsonPropertyName("remarks")] IReadOnlyList<string> Remarks,
|
||||
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations);
|
||||
|
||||
public sealed record LnmLinksetPage(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<LnmLinksetResponse> Items,
|
||||
[property: JsonPropertyName("page")] int Page,
|
||||
[property: JsonPropertyName("pageSize")] int PageSize,
|
||||
[property: JsonPropertyName("total")] int? Total);
|
||||
|
||||
public sealed record LnmLinksetNormalized(
|
||||
[property: JsonPropertyName("purls")] IReadOnlyList<string>? Purls,
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases,
|
||||
[property: JsonPropertyName("purl")] IReadOnlyList<string>? Purl,
|
||||
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions,
|
||||
[property: JsonPropertyName("ranges")] IReadOnlyList<object>? Ranges,
|
||||
[property: JsonPropertyName("severities")] IReadOnlyList<object>? 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<string>? 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<string>? 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<string>? Purl,
|
||||
[property: JsonPropertyName("cpe")] IReadOnlyList<string>? 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);
|
||||
|
||||
@@ -118,6 +118,7 @@ builder.Services.AddOptions<AdvisoryObservationEventPublisherOptions>()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddConcelierAocGuards();
|
||||
builder.Services.AddConcelierLinksetMappers();
|
||||
builder.Services.TryAddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
|
||||
builder.Services.AddSingleton<IMeterFactory>(MeterProvider.Default.GetMeterProvider());
|
||||
builder.Services.AddSingleton<LinksetCacheTelemetry>();
|
||||
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<LnmLinksetConflict>(),
|
||||
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<string>(),
|
||||
Versions = ls.Normalized?.Versions ?? Array.Empty<string>()
|
||||
}),
|
||||
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<AdvisoryLinkset> Items, int? Total)> QueryPageAsync(
|
||||
IAdvisoryLinksetQueryService queryService,
|
||||
string tenant,
|
||||
IEnumerable<string>? advisoryIds,
|
||||
IEnumerable<string>? 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<AdvisoryLinkset>(), exhaustedTotal);
|
||||
}
|
||||
|
||||
cursor = result.NextCursor;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return (Array.Empty<AdvisoryLinkset>(), 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<AdvisoryLinksetConflict>()).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<LnmLinksetConflict>();
|
||||
|
||||
var timeline = includeTimeline
|
||||
? Array.Empty<LnmLinksetTimeline>() // timeline not yet captured in linkset store
|
||||
: Array.Empty<LnmLinksetTimeline>();
|
||||
|
||||
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<string>(),
|
||||
Array.Empty<string>(),
|
||||
Summary: null,
|
||||
PublishedAt: linkset.CreatedAt,
|
||||
ModifiedAt: linkset.CreatedAt,
|
||||
Severity: null,
|
||||
Status: "fact-only",
|
||||
provenance,
|
||||
conflicts,
|
||||
timeline,
|
||||
normalizedDto,
|
||||
Cached: false,
|
||||
Remarks: Array.Empty<string>(),
|
||||
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
|
||||
}
|
||||
|
||||
IResult JsonResult<T>(T value, int? statusCode = null)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, jsonOptions);
|
||||
|
||||
@@ -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 }
|
||||
@@ -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<string>(set, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
|
||||
var seed = packageKeysPerInput.FirstOrDefault() ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
@@ -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<JsonElement>();
|
||||
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<JsonElement>();
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PackAsTool>true</PackAsTool>
|
||||
<ToolCommandName>stella-forensic-verify</ToolCommandName>
|
||||
<PackageOutputPath>../../out/tools</PackageOutputPath>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user