18 KiB
Here’s a practical, first‑time‑friendly guide to using VEX in Stella Ops, plus a concrete .NET pattern you can drop in today.
VEX in a nutshell
- VEX (Vulnerability Exploitability eXchange): a small JSON document that says whether specific CVEs actually affect a product/version.
- OpenVEX: SBOM‑agnostic; references products/components directly (URIs, PURLs, hashes). Great for canonical internal models.
- CycloneDX VEX / SPDX VEX: tie VEX statements closely to a specific SBOM instance (component BOM ref IDs). Great when the BOM is your source of truth.
Our strategy:
- Store VEX separately from SBOMs (deterministic, easier air‑gap bundling).
- Link by strong references (PURLs + content hashes + optional SBOM component IDs).
- Translate on ingest between OpenVEX ↔ CycloneDX VEX as needed so downstream tools stay happy.
Translation model (OpenVEX ↔ CycloneDX VEX)
-
Identity mapping
- Prefer PURL for packages; fallback to SHA256 (or SHA512) of artifact; optionally include SBOM
bom-refif known.
- Prefer PURL for packages; fallback to SHA256 (or SHA512) of artifact; optionally include SBOM
-
Product scope
- OpenVEX “product” → CycloneDX
affectswithbom-ref(if available) or a synthetic ref derived from PURL/hash.
- OpenVEX “product” → CycloneDX
-
Status mapping
affected | not_affected | under_investigation | fixedmap 1:1.- Keep timestamps, justification, impact statement, and origin.
-
Evidence
- Preserve links to advisories, commits, tests; attach as CycloneDX
analysis/evidencenotes (or OpenVEXmetadata/notes).
- Preserve links to advisories, commits, tests; attach as CycloneDX
Collision rules (deterministic):
-
New statement wins if:
- Newer
timestampand - Higher provenance trust (signed by vendor/Authority) or equal with a lexicographic tiebreak (issuer keyID).
- Newer
Storage model (MongoDB‑friendly)
-
Collections
vex.documents– one doc per VEX file (OpenVEX or CycloneDX VEX).vex.statements– flattened, one per (product/component, vuln).artifacts– canonical component index (PURL, hashes, optional SBOM refs).
-
Reference keys
artifactKey = purl || sha256 || (groupId:name:version for .NET/NuGet)vulnKey = cveId || ghsaId || internalId
-
Deterministic IDs
_id = sha256(canonicalize(statement-json-without-signature))
-
Signatures
- Keep DSSE/Sigstore envelopes in
vex.documents.signatures[]for audit & replay.
- Keep DSSE/Sigstore envelopes in
Air‑gap bundling
Package SBOMs + VEX + artifacts index + trust roots as a single tarball:
/bundle/
sboms/*.json
vex/*.json # OpenVEX & CycloneDX VEX allowed
index/artifacts.jsonl # purl, hashes, bom-ref map
trust/rekor.merkle.roots
trust/fulcio.certs.pem
trust/keys/*.pub
manifest.json # content list + sha256 + issuedAt
- Deterministic replay: re‑ingest is pure function of bundle bytes → identical DB state.
.NET 10 implementation (C#) – deterministic ingestion
Core models
public record ArtifactRef(
string? Purl,
string? Sha256,
string? BomRef);
public enum VexStatus { Affected, NotAffected, UnderInvestigation, Fixed }
public record VexStatement(
string StatementId, // sha256 of canonical payload
ArtifactRef Artifact,
string VulnId, // e.g., "CVE-2024-1234"
VexStatus Status,
string? Justification,
string? ImpactStatement,
DateTimeOffset Timestamp,
string IssuerKeyId, // from DSSE/Signing
int ProvenanceScore); // Authority policy
Canonicalizer (stable order, no env fields)
static string Canonicalize(VexStatement s)
{
var payload = new {
artifact = new { s.Artifact.Purl, s.Artifact.Sha256, s.Artifact.BomRef },
vulnId = s.VulnId,
status = s.Status.ToString(),
justification = s.Justification,
impact = s.ImpactStatement,
timestamp = s.Timestamp.UtcDateTime
};
// Use System.Text.Json with deterministic ordering
var opts = new System.Text.Json.JsonSerializerOptions {
WriteIndented = false
};
string json = System.Text.Json.JsonSerializer.Serialize(payload, opts);
// Normalize unicode + newline
json = json.Normalize(NormalizationForm.FormKC).Replace("\r\n","\n");
return json;
}
static string Sha256(string s)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
Ingest pipeline
public sealed class VexIngestor
{
readonly IVexParser _parser; // OpenVEX & CycloneDX adapters
readonly IArtifactIndex _artifacts;
readonly IVexRepo _repo; // Mongo-backed
readonly IPolicy _policy; // tie-break rules
public async Task IngestAsync(Stream vexJson, SignatureEnvelope? sig)
{
var doc = await _parser.ParseAsync(vexJson); // yields normalized statements
var issuer = sig?.KeyId ?? "unknown";
foreach (var st in doc.Statements)
{
var canon = Canonicalize(st);
var id = Sha256(canon);
var withMeta = st with {
StatementId = id,
IssuerKeyId = issuer,
ProvenanceScore = _policy.Score(sig, st)
};
// Upsert artifact (purl/hash/bomRef)
await _artifacts.UpsertAsync(withMeta.Artifact);
// Deterministic merge
var existing = await _repo.GetAsync(id)
?? await _repo.FindByKeysAsync(withMeta.Artifact, st.VulnId);
if (existing is null || _policy.IsNewerAndStronger(existing, withMeta))
await _repo.UpsertAsync(withMeta);
}
if (sig is not null) await _repo.AttachSignatureAsync(doc.DocumentId, sig);
}
}
Parsers (adapters)
OpenVexParser– reads OpenVEX; emitsVexStatementwithArtifactRef(PURL/hash)CycloneDxVexParser– resolvesbom-ref→ look up PURL/hash viaIArtifactIndex(if SBOM present); if not, store bom‑ref and mark artifact unresolved for later backfill.
Why this works for Stella Ops
- SBOM‑agnostic core (OpenVEX‑first) maps cleanly to your MongoDB canonical stores and
.NET 10services. - SBOM‑aware edges (CycloneDX VEX) are still supported via adapters and
bom-refbackfill. - Deterministic everything: canonical JSON → SHA256 IDs → reproducible merges → perfect for audits and offline environments.
- Air‑gap ready: single bundle with trust roots, replayable on any node.
Next steps (plug‑and‑play)
-
Implement the two parsers (
OpenVexParser,CycloneDxVexParser). -
Add the repo/index interfaces to your
StellaOps.Vexerservice:IVexRepo(Mongo collectionsvex.documents,vex.statements)IArtifactIndex(your canonical PURL/hash map)
-
Wire
Policyto Authority to score signatures and apply tie‑breaks. -
Add a
bundle ingestCLI:vexer ingest /bundle/manifest.json. -
Expose GraphQL (HotChocolate) queries:
vexStatements(artifactKey, vulnId),vexStatus(artifactKey),evidence(...).
If you want, I can generate the exact Mongo schemas, HotChocolate types, and a minimal test bundle to validate the ingest end‑to‑end.
Below is a complete, developer-ready implementation plan for the VEX ingestion, translation, canonicalization, storage, and merge-policy pipeline inside Stella Ops.Vexer, aligned with your architecture, deterministic requirements, MongoDB model, DSSE/Authority workflow, and .NET 10 standards.
This is structured so an average developer can follow it step-by-step without ambiguity. It is broken into phases, each with clear tasks, acceptance criteria, failure modes, interfaces, and code pointers.
Stella Ops.Vexer
Full Implementation Plan (Developer-Executable)
1. Core Objectives
Develop a deterministic, replayable, SBOM-agnostic but SBOM-compatible VEX subsystem supporting:
- OpenVEX and CycloneDX VEX ingestion.
- Canonicalization → SHA-256 identity.
- Cross-linking to artifacts (purl, hash, bom-ref).
- Merge policies driven by Authority trust/lattice rules.
- Complete offline reproducibility.
- MongoDB canonical storage.
- Exposed through gRPC/REST/GraphQL.
2. Module Structure (to be implemented)
src/StellaOps.Vexer/
Application/
Commands/
Queries/
Ingest/
Translation/
Merge/
Policies/
Domain/
Entities/
ValueObjects/
Services/
Infrastructure/
Mongo/
AuthorityClient/
Hashing/
Signature/
BlobStore/
Presentation/
GraphQL/
REST/
gRPC/
Every subfolder must compile in strict mode (treat warnings as errors).
3. Data Model (MongoDB)
3.1 vex.statements collection
Document schema:
{
"_id": "sha256(canonical-json)",
"artifact": {
"purl": "pkg:nuget/... or null",
"sha256": "hex or null",
"bomRef": "optional ref",
"resolved": true | false
},
"vulnId": "CVE-XXXX-YYYY",
"status": "affected | not_affected | under_investigation | fixed",
"justification": "...",
"impact": "...",
"timestamp": "2024-01-01T12:34:56Z",
"issuerKeyId": "FULCIO-KEY-ID",
"provenanceScore": 0–100,
"documentId": "UUID of vex.documents entry",
"sourceFormat": "openvex|cyclonedx",
"createdAt": "...",
"updatedAt": "..."
}
3.2 vex.documents collection
{
"_id": "<uuid>",
"format": "openvex|cyclonedx",
"rawBlobId": "<blob-id in blobstore>",
"signatures": [
{
"type": "dsse",
"verified": true,
"issuerKeyId": "F-123...",
"timestamp": "...",
"bundleEvidence": {...}
}
],
"ingestedAt": "...",
"statementIds": ["sha256-1", "sha256-2", ...]
}
4. Components to Implement
4.1 Parsing Layer
Interfaces
public interface IVexParser
{
ValueTask<ParsedVexDocument> ParseAsync(Stream jsonStream);
}
public sealed record ParsedVexDocument(
string DocumentId,
string Format,
IReadOnlyList<ParsedVexStatement> Statements);
Tasks
-
Implement
OpenVexParser.- Use System.Text.Json source generators.
- Validate OpenVEX schema version.
- Extract product → component mapping.
- Map to internal
ArtifactRef.
-
Implement
CycloneDxVexParser.- Support 1.5+ “vex” extension.
- bom-ref resolution through
IArtifactIndex. - Mark unresolved
bom-refbut store them.
Acceptance Criteria
- Both parsers produce identical internal representation of statements.
- Unknown fields must not corrupt canonicalization.
- 100% deterministic mapping for same input.
4.2 Canonicalizer
Implement deterministic ordering, UTF-8 normalization, stable JSON.
Tasks
-
Create
Canonicalizerclass. -
Apply:
- Property order: artifact, vulnId, status, justification, impact, timestamp.
- Remove optional metadata (issuerKeyId, provenance).
- Normalize Unicode → NFKC.
- Replace CRLF → LF.
-
Generate SHA-256.
Interface
public interface IVexCanonicalizer
{
string Canonicalize(VexStatement s);
string ComputeId(string canonicalJson);
}
Acceptance Criteria
- Hash identical on all OS, time, locale, machines.
- Replaying the same bundle yields same
_id.
4.3 Authority / Signature Verification
Tasks
-
Implement DSSE envelope reader.
-
Integrate Authority client:
- Verify certificate chain (Fulcio/GOST/eIDAS etc).
- Obtain trust lattice score.
- Produce
ProvenanceScore: int.
Interface
public interface ISignatureVerifier
{
ValueTask<SignatureVerificationResult> VerifyAsync(Stream payload, Stream envelope);
}
Acceptance Criteria
- If verification fails → Vexer stores document but flags signature invalid.
- Scores map to priority in merge policy.
4.4 Merge Policies
Implement Default Policy
-
Newer timestamp wins.
-
If timestamps equal:
- Higher provenance score wins.
- If both equal, lexicographically smaller issuerKeyId wins.
Interface
public interface IVexMergePolicy
{
bool ShouldReplace(VexStatement existing, VexStatement incoming);
}
Acceptance Criteria
- Merge decisions reproducible.
- Deterministic ordering even when values equal.
4.5 Ingestion Pipeline
Steps
-
Accept
multipart/form-dataor referenced blob ID. -
Parse via correct parser.
-
Verify signature (optional).
-
For each statement:
- Canonicalize.
- Compute
_id. - Upsert artifact into
artifacts(viaIArtifactIndex). - Resolve bom-ref (if CycloneDX).
- Existing statement? Apply merge policy.
- Insert or update.
-
Create
vex.documentsentry.
Class
VexIngestService
Required Methods
public Task<IngestResult> IngestAsync(VexIngestRequest request);
Acceptance Tests
- Idempotent: ingesting same VEX repeated → DB unchanged.
- Deterministic under concurrency.
- Air-gap replay produces identical DB state.
4.6 Translation Layer
Implement two converters:
OpenVexToCycloneDxTranslatorCycloneDxToOpenVexTranslator
Rules
- Prefer PURL → hash → synthetic bom-ref.
- Single VEX statement → one CycloneDX “analysis” entry.
- Preserve justification, impact, notes.
Acceptance Criteria
- Round-trip OpenVEX → CycloneDX → OpenVEX produces equal canonical hashes (except format markers).
4.7 Artifact Index Backfill
Reason
CycloneDX VEX may refer to bom-refs not yet known at ingestion.
Tasks
-
Store unresolved artifacts.
-
Create background
BackfillWorker:- Watches
sboms.documentsingestion events. - Matches bom-refs.
- Updates statements with resolved PURL/hashes.
- Recomputes canonical JSON + SHA-256 (new version stored as new ID).
- Watches
-
Marks old unresolved statement as superseded.
Acceptance Criteria
- Backfilling is monotonic: no overwriting original.
- Deterministic after backfill: same SBOM yields same final ID.
4.8 Bundle Ingestion (Air-Gap Mode)
Structure
bundle/
sboms/*.json
vex/*.json
index/artifacts.jsonl
trust/*
manifest.json
Tasks
-
Implement
BundleIngestService. -
Stages:
- Validate manifest + hashes.
- Import trust roots (local only).
- Ingest SBOMs first.
- Ingest VEX documents.
-
Reproduce same IDs on all nodes.
Acceptance Criteria
- Byte-identical bundle → byte-identical DB.
- Works offline completely.
5. Interfaces for GraphQL/REST/gRPC
Expose:
Queries
vexStatement(id)vexStatementsByArtifact(purl/hash)vexStatus(purl)→ latest merged statusvexDocument(id)affectedComponents(vulnId)
Mutations
ingestVexDocumenttranslateVex(format)exportVexDocument(id, targetFormat)replayBundle(bundleId)
All responses must include deterministic IDs.
6. Detailed Developer Tasks by Sprint
Sprint 1: Foundation
- Create solution structure.
- Add Mongo DB contexts.
- Implement data entities.
- Implement hashing + canonicalizer.
- Implement IVexParser interface.
Sprint 2: Parsers
- Implement OpenVexParser.
- Implement CycloneDxParser.
- Develop strong unit tests for JSON normalization.
Sprint 3: Signature & Authority
- DSSE envelope reader.
- Call Authority to verify signatures.
- Produce provenance scores.
Sprint 4: Merge Policy Engine
- Implement deterministic lattice merge.
- Unit tests: 20+ collision scenarios.
Sprint 5: Ingestion Pipeline
- Implement ingest service end-to-end.
- Insert/update logic.
- Add GraphQL endpoints.
Sprint 6: Translation Layer
- OpenVEX↔CycloneDX converter.
- Tests for round-trip.
Sprint 7: Backfill System
- Bom-ref resolver worker.
- Rehashing logic for updated artifacts.
- Events linking SBOM ingestion to backfill.
Sprint 8: Air-Gap Bundle
- BundleIngestService.
- Manifest verification.
- Trust root local loading.
Sprint 9: Hardening
- Fuzz parsers.
- Deterministic stress tests.
- Concurrency validation.
- Storage compaction.
7. Failure Handling Matrix
| Failure | Action | Logged? | Retries |
|---|---|---|---|
| Invalid JSON | Reject document | Yes | 0 |
| Invalid schema | Reject | Yes | 0 |
| Signature invalid | Store document, mark signature invalid | Yes | 0 |
| Artifact unresolved | Store unresolved, enqueue backfill | Yes | 3 |
| Merge conflict | Apply policy | Yes | 0 |
| Canonical mismatch | Hard fail | Yes | 0 |
8. Developer Unit Test Checklist
must have tests for:
- Canonicalization stability (100 samples).
- Identical input twice → identical
_id. - Parsing OpenVEX with multi-product definitions.
- Parsing CycloneDX with missing bom-refs.
- Merge policy tie-breakers.
- Air-gap replay reproducibility.
- Translation equivalence.
9. Deliverables for Developers
They must produce:
- Interfaces + DTOs + document schemas.
- Canonicalizer with 100% deterministic output.
- Two production-grade parsers.
- Signature verification pipeline.
- Merge policies aligned with Authority trust model.
- End-to-end ingestion service.
- Translation layer.
- Backfill worker.
- Air-gap bundle script + service.
- GraphQL APIs.
If you want, I can next produce:
- A full developer handbook (60–90 pages).
- Full technical architecture ADRs.
- A concrete scaffold with compiles-clean
.NET 10project. - Complete test suite specification.
- A README.md for new joiners.