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:
- The highest-authority VEX we can ingest — first-party, image-specific, cryptographically signed. A verified vendor “not_affected” attached to the digest you actually run is stronger evidence than a distro-level CSAF.
- The most security-sensitive — a VEX statement suppresses or affirms findings. A forged
not_affectedis a way to hide a real CVE. So the product question is how trust is bound, not how bytes are fetched.
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)
StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest— anIVexConnectorthat discovers images, authenticates (cosign OIDC/key), fetches DSSE envelopes, verifies signatures via the Attestation module, persists raw →OpenVexNormalizer→ claims. Already supports an explicit image list and offline file seeds (air-gap). Provider idexcititor:oci-openvex, currentlyenabled=f.- Scanner referrer ingest (
OciReferrerIngestService,OciReferrerIngestEndpoints, ADR-024) — the publish/internal-first side: Stella as system-of-record for referrers (publishExternally:false). - Trust Lattice (
architecture.md§7.1) — provider tier weights incl. anattestationtier; the merge/scoring already accommodates a high-trust signed input.
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:
- Stella already touches every image by digest during scan/inventory. At that point, query the registry Referrers API for an attached VEX and route any hit through the existing connector fetch → verify → normalize path.
- The “image list” becomes the live estate; registry credentials come from the unified registry-access layer (ADR-033, masked refs / per-registry creds) — no second credential store.
- Pull-on-scan ⇒ zero feed maintenance, and work is bounded to images actually in use.
5) Trust model (the security-manager core)
Unlike Oracle/SUSE (genuinely unsigned upstream ⇒ cache-only), OCI-OpenVEX should be fail-closed by signer identity:
- Per-publisher trust policy. “Accept VEX attestations on images under
registry.acme.com/*only if signed by Fulcio identityrelease@acme.com(or a named key / certificate-identity).” Unsigned, or signed by an unlisted identity ⇒ quarantined, never auto-applied. - 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.
- VEX + reachability = highest-confidence suppression. A verified vendor
not_affectedplus 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
- Advisory & VEX sources page. Render OCI-OpenVEX unlike a feed — not “last checked,” but “discovered on N images, M publishers (K trusted)”, plus the per-publisher trust-policy editor.
- Finding detail (audit trail). When a finding is suppressed/affirmed by an image-attached VEX, show provenance inline: “Suppressed by signed vendor VEX, attached to this digest, signed by
release@acme.com, verified ✓”, one click to the attestation. - VexLens consensus. Weight a verified first-party OCI attestation above distro-level CSAF; surface conflicts (vendor
not_affectedvs distroaffected) instead of silently picking a winner.- Implemented (TASK-7, VexLens half).
excititor:oci-openvexreaches VexLens asVexIssuer.Id(set from the provider/source id). The trust engine (src/VexLens/StellaOps.VexLens/Trust/TrustWeightEngine.cs) recognizes that id viaTrustWeightConfiguration.FirstPartyAttestationIssuerIds(default{excititor:oci-openvex}, operator-overridable) and appliesIssuerTrustWeights.FirstPartyAttestationMultiplier(default 1.3) instead of the category multiplier — strictly above Vendor (1.0) > Distributor (0.9), so a first-party attestation outranks distro/distributor CSAF without a config edit. (MaximumWeightis 2.0 so the first-party weight is not clamped to the vendor ceiling.) The consensus engine (Consensus/VexConsensusEngine.cs) records every differing-status pair as aConsensusConflictinVexConsensusResult.Conflictsand keeps the losing distro claim inContributions(IsWinner=false); the conflictResolutionnames the first-party attestation when it wins on weight. The OCI claim wins the verdict (higher weight) and the conflict is visible.
- Implemented (TASK-7, VexLens half).
7) Rollout (phased, low-risk)
- 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.
- Phase 2 — trust policy + auto-apply. Per-publisher signer allowlist; trusted signers’ VEX flows into consensus/findings; everything else stays advisory/quarantined.
- 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:
- verifier returns metadata →
verified(signature metadata + AOC lineage stamps merged). - verifier returns null (unsigned) →
unsignedonly if the deployment opts in (X-StellaOps-Allow-Unsigned: true); otherwise →quarantined. - verifier throws
VexSignatureRequiredException(tampered/invalid) →quarantined, even when allowUnsigned is set. - Quarantined docs are written to
IVexQuarantineSinkfor audit and are never normalized and never produce a claim.
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)):
| column | meaning |
|---|---|
registry_glob | registry-host glob, e.g. registry.acme.com or *.acme.com |
repo_glob | repository-path glob, e.g. team/*; null = any repo |
signer_issuer | Fulcio OIDC issuer / signer issuer; null = any verified issuer |
signer_subject_pattern | signer identity / cert-identity / keyId glob; null = any |
mode | auto-apply (trusted ⇒ enforced) or advisory (held back) |
enabled | disabled 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:
| method | route | purpose |
|---|---|---|
| GET | /excititor/oci-trust-policies | list rules (ordered registry_glob, repo_glob, id) |
| POST | /excititor/oci-trust-policies | create 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.ResolveAsync → enforced|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:
| method | route | purpose |
|---|---|---|
| POST | /excititor/export/oci-attestations/bundle | dump 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/bundle | import 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), thenPOST /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)
| Item | State |
|---|---|
| Connector: discover/auth/fetch DSSE/verify/normalize/offline seed | done |
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 gate | gate done (enforced mode flows to resolve→VexLens); provenance UI missing (TASK-8) |
| Consensus weighting for first-party signed VEX | partial (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.
