Files
git.stella-ops.org/docs-archived/product-advisories/22-Dec-2026 - Building a Trust Lattice for VEX Sources.md
2026-01-05 16:02:11 +02:00

7.4 KiB
Raw Blame History

Here's a compact, practical way to make VEX trust decisions explainable and replayable across vendor, distro, and internal sources—without adding human-in-the-loop friction.


VEX Trust Lattice (compact)

Goal: turn messy/contradictory VEX claims into a single, signed, reproducible verdict with a numeric confidence and an audit trail.

1) Trust vector per source

Each VEX source S gets a 3component trust vector scored in [0..1]:

  • Provenance (P): cryptographic & process integrity

    • 1.00 = DSSEsigned, timestamped, Rekor/Git tag anchored, org DKIM/Sigstore OIDC, key in allowlist, rotation policy OK
    • 0.75 = DSSEsigned + public key known, but no transparency log
    • 0.40 = unsigned but retrieved via authenticated, immutable artifact repo
    • 0.10 = opaque/CSV/email/manual import
  • Coverage (C): how well the statement's scope maps to your asset

    • 1.00 = exact package + version/build digest + feature/flag context matched
    • 0.75 = exact pkg + version range matched; partial feature context
    • 0.50 = productlevel only; maps via CPE/PURL family
    • 0.25 = familylevel heuristics; no version proof
  • Replayability (R): can we deterministically rederive the claim?

    • 1.00 = all inputs pinned (feeds, SBOM hash, ruleset hash, lattice version); replays byteidentical
    • 0.60 = inputs mostly pinned; nondeterministic ordering tolerated but stable outcome
    • 0.20 = ephemeral APIs; no snapshot

BaseTrust(S) = wP·P + wC·C + wR·R (defaults: wP=0.45, wC=0.35, wR=0.20; tunable per policy).

2) Claim strength & freshness

Every individual VEX claim S asserts X carries multipliers:

  • Strength (M): notaffectedbecause{reason}

    • Exploitability analysis + reachability proof subgraph provided → 1.00
    • Config/featureflag reason with evidence → 0.80
    • Vendor blanket statement → 0.60
    • "Under investigation" → 0.40
  • Freshness (F): timedecay curve; default halflife 90 days

    • F = exp(- ln(2) · age_days / 90); floor at 0.35 unless revoked

ClaimScore = BaseTrust(S) · M · F.

3) Lattice ordering & merge

Define a partial order on claims by (scope specificity, ClaimScore). More specific scope wins ties.

For a given CVE×Asset, gather all claims {Ci}:

  • If any revocation/contradiction exists, keep both and trigger conflict mode: require replay proof; otherwise downweight older/weaker by Δ=0.25.
  • Final verdict chosen by argmax(ClaimScore) after conflict adjustments.

Return tuple:

Verdict = {
  status: {affected|not_affected|under_investigation|fixed},
  confidence: ClaimScore*,
  expl: list of (source, reason, P/C/R, M, F),
  evidence_refs: [attestations, SBOM hash, reachability subgraph id],
  policy_hash, lattice_version
}

4) Policy hooks (explainable gates)

  • Minimum confidence by environment: e.g., prod requires ≥0.75 to accept "not_affected".
  • Unknowns budget: fail if (#unknown deps > N) OR (Σ(1ClaimScore) over unknowns > T).
  • Source quotas: cap influence from any single vendor at 60% unless a second independent source supports within Δ=0.1.
  • Reason allowlist: forbid blanket vendor claims for criticals unless reachability proof exists.

5) Deterministic replay

To guarantee "same inputs → same verdict":

  • Pin: SBOM digest(s), vuln feed snapshot ids, VEX document digests, reachability graph ids, policy file, lattice version, clock cutoff.
  • Sort: stable topological order on inputs (by (issuer_did, statement_digest)).
  • Serialize verdict + inputs into a Verdict Manifest (JSON/CBOR) and sign (DSSE).
  • Store in Authority with index: (asset_digest, CVE, policy_hash, lattice_version).

6) Minimal data model (for Vexer/Policy Engine)

{
  "source": {
    "id": "did:web:vendor.example",
    "provenance": {"sig_type":"dsse","rekor_log_id":"...","key_alias":"vendor_k1"},
    "provenance_score": 0.90,
    "coverage_score": 0.75,
    "replay_score": 0.60,
    "weights": {"wP":0.45,"wC":0.35,"wR":0.20}
  },
  "claim": {
    "scope": {"purl":"pkg:rpm/openssl@3.0.12-5","digest":"sha256:...","features":{"fips":true}},
    "cve": "CVE-2025-12345",
    "status": "not_affected",
    "reason": "feature_flag_off",
    "strength": 0.80,
    "issued_at": "2025-11-28T10:12:00Z",
    "evidence": {"reach_subgraph_id":"reg:subg/abcd","attestation":"sha256:..."}
  },
  "policy": {"min_confidence_prod":0.75,"unknown_budget":5,"require_reachability_for_criticals":true},
  "lattice_version": "1.2.0"
}

7) Deterministic evaluation (C# sketch)

public record TrustWeights(double wP=0.45, double wC=0.35, double wR=0.20);

double BaseTrust(double P, double C, double R, TrustWeights W)
  => W.wP*P + W.wC*C + W.wR*R;

double Freshness(DateTime issuedAt, DateTime cutoff, double halfLifeDays=90, double floor=0.35)
{
    var age = (cutoff - issuedAt).TotalDays;
    var f = Math.Exp(-Math.Log(2) * age / halfLifeDays);
    return Math.Max(f, floor);
}

double ClaimScore(Source s, Claim c, TrustWeights W, DateTime cutoffUtc)
{
    var baseTrust = BaseTrust(s.P, s.C, s.R, W);
    var freshness  = Freshness(c.IssuedAt, cutoffUtc);
    return baseTrust * c.Strength * freshness;
}

// Merge: pick best score; apply conflict penalty if contradictory present
Verdict Merge(IEnumerable<(Source S, Claim C)> claims, Policy policy, DateTime cutoffUtc)
{
    var scored = claims.Select(t => new {
        t.S, t.C, Score = ClaimScore(t.S, t.C, t.S.Weights, cutoffUtc)
    }).ToList();

    bool contradictory = scored.Select(x=>x.C.Status).Distinct().Count() > 1;
    if (contradictory) {
        scored = scored.Select(x => new {
            x.S, x.C, Score = x.Score * 0.75 // conflict penalty
        }).ToList();
    }

    var winner = scored.OrderByDescending(x => (x.C.Scope.Specificity, x.Score)).First();
    if (policy.RequireReachForCriticals && winner.C.IsCritical && !winner.C.HasReachabilityProof)
        return Verdict.FailGate("No reachability proof for critical");

    if (policy.MinConfidenceProd.HasValue && winner.Score < policy.MinConfidenceProd)
        return Verdict.FailGate("Below minimum confidence");

    return Verdict.Accept(winner.C.Status, winner.Score, AuditTrail.From(scored));
}

8) UI: "Trust Algebra" panel (1 screen, no new page)

  • Header: CVE × Asset digest → final status + confidence meter.
  • Stacked bars: P/C/R contributions for the winning claim.
  • Claim table: source, status, reason, P/C/R, strength, freshness, ClaimScore; toggle "show conflicts".
  • Policy chips: which gates applied; click to open policy YAML/JSON (readonly if in replay).
  • Replay button: "Reproduce verdict" → emits a signed Verdict Manifest and logs proof ids.

9) Defaults for source classes

  • Vendor: P=0.9, C=0.7 (often coarse), R=0.6
  • Distro: P=0.8, C=0.85 (buildaware), R=0.6
  • Internal: P=0.85 (orgsigned), C=0.95 (exact SBOM+reach), R=0.9

Tune per issuer using rolling calibration: compare past ClaimScores vs. postmortem truth; adjust via small learning rate (±0.02/epoch) under a signed calibration manifest (also replayable).


If you want, I can drop this into your Stella Ops modules today as:

  • Vexer: trustvector store + claim normalizer
  • Policy Engine: lattice evaluator + gates
  • Authority: verdict manifest signer/indexer
  • UI: single "Trust Algebra" panel wired to evidence ids

Say the word and I'll generate the concrete JSON schemas, C# interfaces, and a seed policy file.