OCI-OpenVEX attestations — estate-driven integration

Status: design / sprint-tracked (see docs/implplan/SPRINT_20260628_005_Concelier_oci_openvex_estate_integration.md). Owner module: Excititor (src/Concelier/__Libraries/StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest). Cross-module: Scanner (discovery hook), Web (UI), VexLens (consensus weighting). Related: ADR-024 (internal-first OCI metadata), ADR-033 (unified registry access), architecture.md §7.1 (Trust Lattice), §9 (Attestation integration).

1) Why OCI-OpenVEX is different from every other VEX source

Every feed-based VEX source (RedHat/Ubuntu/Oracle/SUSE/MSRC CSAF; OpenVEX feeds) is a publisher-level statement: “distro X says CVE-Y is not-affected in package Z.” OCI-OpenVEX is the image author asserting, against one exact image digest, that a CVE does or does not apply — and signing it with cosign/DSSE.

That makes it simultaneously:

Corollary for the product: an unverified attestation must never downgrade a finding. Trust binding is the feature.

2) Not a feed — pull-on-scan

OCI-OpenVEX is not a feed you poll, which is exactly why it cannot be enabled like the CSAF providers. The documents are stored as OCI artifacts attached to specific images (cosign attestation / OCI Referrers API). Ingestion is therefore per-image, at scan time:

scan image (by digest)  →  query registry Referrers API for that digest
                        →  found a VEX attestation?
                        →  fetch DSSE envelope  →  verify signature + signer identity
                        →  normalize (OpenVexNormalizer)  →  vex.claims  →  VexLens consensus

There is nothing central to crawl. It only ever does work for images in the operator’s estate (the images Stella already scans), and only produces data for those that actually carry an attached attestation.

3) What already exists (do not rebuild)

The fetch → verify → normalize → offline-seed pieces are done. The gap is product wiring: nothing auto-feeds the connector the estate, decides per-publisher trust, or surfaces provenance on a finding.

4) Integration model — estate-driven, not configured

Do not make the operator maintain a static image list; that is the trap that keeps the provider disabled. Instead hook referrer discovery into the scan/inventory lifecycle:

5) Trust model (the security-manager core)

Unlike Oracle/SUSE (genuinely unsigned upstream ⇒ cache-only), OCI-OpenVEX should be fail-closed by signer identity:

  1. Per-publisher trust policy. “Accept VEX attestations on images under registry.acme.com/* only if signed by Fulcio identity release@acme.com (or a named key / certificate-identity).” Unsigned, or signed by an unlisted identity ⇒ quarantined, never auto-applied.
  2. Two-stage application gate (AOC-style). A discovered attestation is advisory-only by default (visible, not acting on findings) until the operator promotes that signer to trusted → auto-apply. No silent suppression on first sight.
  3. VEX + reachability = highest-confidence suppression. A verified vendor not_affected plus Stella’s own reachability verdict “not reachable” is the strongest, most auditable suppression we can produce — two legs of evidence, neither an unverified assertion.

This is the one VEX source where the security manager gets a cryptographic trust decision rather than a reputational one — lean into it.

6) Where it surfaces in the product

7) Rollout (phased, low-risk)

  1. Phase 1 — discover-on-scan, advisory-only. Turn on referrer discovery for registries we already have creds for; verify signatures; show everything; apply nothing. Proves the pipeline with zero suppression risk and immediately tells operators which of their images even carry vendor VEX.
  2. Phase 2 — trust policy + auto-apply. Per-publisher signer allowlist; trusted signers’ VEX flows into consensus/findings; everything else stays advisory/quarantined.
  3. Phase 3 — air-gap. A connected site exports a curated attestation set into a deterministic bundle; a sealed site imports it through the same OciAttestationIngestService, so verify + trust-policy (enforced/advisory) + provenance + quarantine are enforced offline exactly as online. See §7c. (Done — TASK-9.)

7a) Direct VEX ingest surface (Scanner → Excititor)

Phase 1 wires the scan-time discovery (TASK-1) to Excititor through a direct-ingest endpoint rather than re-running the pull connector. The Scanner posts the already-discovered referrer payloads; Excititor runs each through the existing raw → verify → normalize → claim pipeline.

POST {excititor-base}/excititor/ingest/oci-attestations
  Auth: X-StellaOps-TenantId header + vex.admin scope
        (ExcititorPolicies.VexAdmin == StellaOpsScopes.VexAdmin "vex.admin" —
         the same scope /excititor/ingest/run requires, both the named policy
         and an in-handler RequireScope check).
  Body:
  {
    "subjectImageDigest": "sha256:<64hex>",
    "registryHost": "registry.example.com",
    "repoPath": "team/app",
    "scanJobId": "<scan job id, optional>",
    "documents": [
      { "artifactType":"application/vnd.openvex+json",
        "mediaType":"application/vnd.cncf.openvex.v1+json",
        "contentBase64":"<base64 raw VEX/DSSE bytes>",
        "sourceUri":"sha256:<referrer-descriptor-digest>",
        "publisherHint":"release@acme.com" }
    ]
  }
  Response 200:
  { "accepted":1,"verified":1,"unsigned":0,"quarantined":0,"claimsAppended":3,
    "results":[ {"sourceUri":"sha256:…","outcome":"verified|unsigned|quarantined|error","claims":3,"detail":""} ] }

Provider id for every ingested doc: excititor:oci-openvex.

Format resolution. application/vnd.openvex+json (or any openvex artifactType / mediaType) → VexDocumentFormat.OpenVex; a dsse/in-toto artifactType/mediaType, or a body shaped { payload: <base64>, signatures: [...] }, → VexDocumentFormat.OciAttestation.

Verification gate (TASK-3). Each document is verified through the registered IVexSignatureVerifier (the unified verifier shared with the worker), mirroring VexConnectorBase.CreateRawDocumentAsync:

Provenance stamps. Every raw doc and every derived claim carries: oci.subject.digest, oci.registry.host, oci.repo.path, oci.scan.jobId, oci.artifact.type, plus vex.application.mode = "advisory". The persisted raw document also carries canonical Boolean strings that reflect the verifier outcome: signature.present=false and signature.verified=false for an unsigned document admitted by the explicit opt-in, and both values are true for a verified document. The transient verifier input may carry signature hints, but it must never leak those hints into stored provenance as a successful result.

Advisory-only guarantee (TASK-2, Phase 1). Claims are appended (visible via the raw claim/observation reads, e.g. /excititor/statements, /console/vex) but are not applied to findings. VexLens computes finding verdicts from the claims returned by /excititor/resolve; that endpoint now excludes any claim stamped vex.application.mode=advisorybefore the claims reach VexLens. Because excititor:oci-openvex is a brand-new provider and nothing else in the apply/consensus path references it, this single gate is what guarantees a discovered not_affected OCI claim does not suppress the matching finding in Phase 1. Phase 2 (signer trust + promote) will clear the advisory mode for trusted signers so their claims flow into consensus.

7b) Per-publisher signer trust policy (TASK-5, Phase 2)

Phase 2 makes the vex.application.mode stamp trust-conditional instead of always advisory. The model is per-publisher (finer-grained than the per-provider cosignIssuer/cosignIdentityPattern on vex.provider_settings).

Storage. Tenant-scoped table vex.oci_trust_policy (embedded-SQL migration 002_oci_trust_policy.sql, auto-applied on startup; PK (tenant_id, id)):

columnmeaning
registry_globregistry-host glob, e.g. registry.acme.com or *.acme.com
repo_globrepository-path glob, e.g. team/*; null = any repo
signer_issuerFulcio OIDC issuer / signer issuer; null = any verified issuer
signer_subject_patternsigner identity / cert-identity / keyId glob; null = any
modeauto-apply (trusted ⇒ enforced) or advisory (held back)
enableddisabled rules never match

Resolution (IOciVexTrustPolicy.ResolveAsync). For a verified doc only, the signer identity (issuer / subject / keyId) is read from the verifier metadata and matched against the tenant’s enabled auto-apply rules (registry+repo globs AND signer issuer AND subject/keyId pattern). First match ⇒ Enforced; otherwise Advisory. This is default-deny: an empty store, a non-matching publisher, a signer mismatch, a disabled rule, or an advisory-mode rule all yield Advisory. An unsigned/quarantined document is never Enforced.

Ingest stamp. A trusted decision stamps vex.application.mode = enforced (vs advisory) on the raw doc and every claim, and persists the matched signer as oci.signer.issuer / oci.signer.subject for the finding-provenance UI (TASK-8). Because /excititor/resolve filters only advisory, an enforced claim flows to VexLens and can change a finding verdict.

CRUD API (TASK-6 backend). Tenant header + vex.admin scope:

methodroutepurpose
GET/excititor/oci-trust-policieslist rules (ordered registry_glob, repo_glob, id)
POST/excititor/oci-trust-policiescreate a rule
GET/excititor/oci-trust-policies/{id}get one rule
PUT/excititor/oci-trust-policies/{id}replace a rule
DELETE/excititor/oci-trust-policies/{id}delete a rule

7c) Air-gap offline bundle (TASK-9, Phase 3)

A sealed (air-gapped) site cannot reach a registry’s Referrers API, so it cannot run the online estate-driven discovery. Phase 3 lets a connected site export a curated set of OCI OpenVEX attestations into a portable bundle, and a sealed site import it — getting the EXACT same verify + per-publisher trust-policy (enforced/advisory) + provenance + quarantine outcome as the online path.

Critical design point — no forked trust logic. The import does not use the connector’s pull path (that path applies the legacy offline-seed read but NOT the new trust-policy stamping, which lives only in OciAttestationIngestService). Instead the importer routes every bundle entry through the same OciAttestationIngestService.IngestAsync the online POST /excititor/ingest/oci-attestations endpoint uses. Offline == online because it is literally the same code: signature verification (IVexSignatureVerifier), IOciVexTrustPolicy.ResolveAsyncenforced|advisory, normalization, provenance stamping, and quarantine are byte-for-byte identical.

Bundle format. A deterministic gzip tarball (OciAttestationBundleSerializer in StellaOps.Excititor.Core/OciTrust/):

manifest.json            schema/version + ordered per-entry provenance
documents/<sha256>.bin   the raw VEX/DSSE document bytes (one per entry)

manifest.json:

{
  "schema": "stellaops.oci-openvex.bundle",
  "version": 1,
  "provider": "excititor:oci-openvex",
  "entries": [
    {
      "subjectImageDigest": "sha256:<64hex>",
      "registryHost": "registry.acme.com",
      "repoPath": "team/app",
      "artifactType": "application/vnd.openvex+json",
      "mediaType": "application/vnd.cncf.openvex.v1+json",
      "sourceUri": "sha256:<referrer-descriptor-digest>",
      "publisherHint": "release@acme.com",
      "scanJobId": null,
      "contentSha256": "sha256:<hash of documents/<sha256>.bin>"
    }
  ]
}

Determinism (offline-first posture, CLAUDE.md): entries are sorted by (subjectImageDigest, repoPath, sourceUri, contentSha256); the manifest is written with a stable property order; tar entries use a fixed Unix-epoch mtime and gzip carries no embedded timestamp — the same input set always yields identical bytes. On read, every stored blob is re-hashed and matched against its manifest contentSha256 (bundle-layer tamper detection); the cryptographic VEX signature is still re-verified on import (the bundle hash is integrity, not authenticity).

Export / import API (TASK-9). Tenant header + vex.admin scope — the same auth as the online direct-ingest endpoint and the airgap import:

methodroutepurpose
POST/excititor/export/oci-attestations/bundledump the tenant’s already-ingested OCI OpenVEX raw docs (provider excititor:oci-openvex) into a deterministic bundle → { provider, bundleBase64, bundleLength }
POST/excititor/ingest/oci-attestations/bundleimport a bundle ({ bundleBase64 }); each entry is routed through OciAttestationIngestService → per-entry outcome { entries, accepted, verified, unsigned, quarantined, claimsAppended, results[] }

Import honours the same X-StellaOps-Allow-Unsigned header as the online endpoint (default fail-closed: unsigned → quarantined). A bundle entry whose signer matches an enabled auto-apply rule on the sealed site is stamped enforced (its claim reaches /excititor/resolve → VexLens); no rule → advisory (filtered); unsigned-without-allow or tampered → quarantined, no claim. This is identical to the online estate-driven outcome by construction.

Workflow: on the connected site, promote the signers you trust and let the estate discover attestations, then POST /excititor/export/oci-attestations/bundle. Carry the bundle across the air gap, recreate the matching trust rules on the sealed site (POST /excititor/oci-trust-policies), then POST /excititor/ingest/oci-attestations/bundle. See the air-gap importer guide (docs/modules/airgap/guides/importer.md) and the offline-bundle-format guide (docs/modules/airgap/guides/offline-bundle-format.md).

8) Build list (gap vs done)

ItemState
Connector: discover/auth/fetch DSSE/verify/normalize/offline seeddone
Air-gap offline bundle (export/import via OciAttestationIngestService)done (TASK-9; §7c) — offline gating == online
Scan → referrer-discovery hook (auto-populate from estate)missing — keystone
Per-publisher signer-identity trust policy (backend + CRUD API)done (TASK-5/6 backend; §7b) — UI editor still missing
Finding-level VEX provenance surfacing + advisory→auto-apply gategate done (enforced mode flows to resolve→VexLens); provenance UI missing (TASK-8)
Consensus weighting for first-party signed VEXpartial (lattice has the tier)
Reuse unified registry-access creds (ADR-033)exists, needs wiring

9) Recommendation

Ship Phase 1 (discover-on-scan, advisory-only) first: it is mostly wiring the existing connector to the scan lifecycle, carries no suppression risk, and immediately yields useful intel (most estates carry little vendor VEX today — knowing that is itself valuable). Phases 2–3 follow once operators have seen the discovered set and can make trust decisions with real data in front of them.