Release notes — Excititor signature verification ingest gate

Sprint: 20260501-020 Audit: docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md §E1 ADR: docs/architecture/decisions/ADR-019-excititor-vex-signature-verification-ingest-gate.md Status: Pre-prod; breaking changes explicitly authorized by user (2026-04-29).

Summary

Audit pass 2 finding §E1 documented that the Excititor connector pipeline admitted unsigned and tampered VEX documents because the default IVexSignatureVerifier binding was a no-op (VerificationDisabledVexSignatureVerifier) and VexConnectorBase never gated raw documents on verification. Sprint 20260501-020 closes the gap by:

  1. Making VexConnectorBase.CreateRawDocumentAsync perform mandatory verification before the digest is computed.
  2. Deleting VerificationDisabledVexSignatureVerifier and routing every V1 call through VexSignatureVerifierV1Adapter, which calls IVexSignatureVerifierV2 and throws VexSignatureRequiredException on missing/invalid signatures.
  3. Adding a hosted startup validator that refuses to start the host when IHostEnvironment.IsProduction() is true and VexSignatureVerification:Connectors:AllowUnsigned=true.

All cryptography is delegated to StellaOps.Cryptography.ICryptoProviderRegistry; no new primitives were introduced.

Removed types

New types

Changed types

Migration: configuration

# Before (silently accepted unsigned VEX):
VexSignatureVerification:
  Enabled: false

# After: production deployment.
VexSignatureVerification:
  Enabled: true
  DefaultProfile: world          # or fips/gost/sm depending on regional crypto
  IssuerDirectory:
    ServiceUrl: https://issuer-directory.<your-domain>
    Timeout: 00:00:05
  Valkey:
    ConnectionString: "valkey:6379,abortConnect=false"
    Database: 5
    KeyPrefix: "excititor:vex-verification:"
  Connectors:
    AllowUnsigned: false         # default; setting true in production WILL refuse to start

Migration: code

Before (legacy, silently accepted unsigned)

public override async IAsyncEnumerable<VexRawDocument> FetchAsync(
    VexConnectorContext context,
    [EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var doc in _fetcher.FetchAsync(...))
    {
        var raw = CreateRawDocument(VexDocumentFormat.Csaf, doc.Uri, doc.Bytes, metadata);
        await context.RawSink.StoreAsync(raw, ct);
        yield return raw;
    }
}

After (fail-closed)

public override async IAsyncEnumerable<VexRawDocument> FetchAsync(
    VexConnectorContext context,
    [EnumeratorCancellation] CancellationToken ct)
{
    var verification = new VexConnectorVerificationOptions
    {
        // Defaults are fail-closed; only set AllowUnsigned in non-production.
        AllowUnsigned = false,
        CryptoProfile = "world",
    };

    await foreach (var doc in _fetcher.FetchAsync(...))
    {
        var raw = await CreateRawDocumentAsync(
            context,
            VexDocumentFormat.Csaf,
            doc.Uri,
            doc.Bytes,
            metadata,
            verification,
            ct);

        await context.RawSink.StoreAsync(raw, ct);
        yield return raw;
    }
}

The OCI connector (OciOpenVexAttestationConnector) is updated in this sprint as the reference implementation. CSAF connectors (RedHat, Cisco, Oracle, Ubuntu, MSRC, Rancher) are migrated in sprint 20260501-021.

Migration: tests

Test fixtures that registered VerificationDisabledVexSignatureVerifier explicitly will fail to compile. Replace with a small fake that returns null for “unsigned” or a populated VexSignatureMetadata for “trusted”:

private sealed class FakeVexSignatureVerifier : IVexSignatureVerifier
{
    public VexSignatureMetadata? Result { get; set; }
    public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken ct)
        => ValueTask.FromResult(Result);
}

Set Cosign:RequireSignature=false in connector settings (or set VexConnectorVerificationOptions.AllowUnsigned=true directly) for tests that want to exercise the legacy “fixture has no real signature” path without provoking a fail-closed exception.

Operator checklist

  1. Configure VexSignatureVerification:IssuerDirectory:ServiceUrl to point at a real Issuer Directory deployment (no offline/in-memory fallback in live runtimes).
  2. Confirm VexSignatureVerification:Connectors:AllowUnsigned is unset or false in your production environment file. The startup validator throws otherwise.
  3. Migrate any custom connector built on CreateRawDocument to CreateRawDocumentAsync before the next sprint promotes the obsolete diagnostic to error: true.
  4. Subscribe alerting rules to the new metric tag set: excititor.connector.signature.verified{result=verified|unsigned|failed} and excititor.connector.signature.failure_reason{reason=...}.
  5. If you enable QuarantineOnFailure, verify vex.excititor_quarantine exists after startup migrations and review docs/ops/excititor-quarantine.md for inspect, replay, and purge handling.

Sprint 20260501-022 addendum — CSAF schema validation as a hard gate

In addition to signature verification, CSAF connectors now run a fail-closed schema validator (CsafSchemaValidator) immediately after signature verification and before raw-sink storage. Documents that pass the signature gate but fail schema validation are rejected with CsafSchemaValidationException carrying structured diagnostics, and the quarantine reason is csaf-schema-invalid:<issue-code>.

Breaking implication

Documents that previously slipped through with malformed shape — e.g. MsrcCsafConnector accepted any well-formed JSON, RedHat/Oracle deferred to the normalizer — are now quarantined at fetch time. Operators may see quarantine rows where ingestion previously succeeded silently with half-populated normalization output.

Sample diagnostic

A malformed MSRC document missing document.tracking.id produces a CsafSchemaValidationException with diagnostics:

csaf.validation.issueCode = csaf-missing-mandatory-key
csaf.validation.pointer   = /document/tracking/id
csaf.validation.message   = Mandatory property '/document/tracking/id' is missing.
csaf.validation.csafVersion = 2.0

Quarantine reason published to the quarantine sink: csaf-schema-invalid:csaf-missing-mandatory-key.

Configuration

The validator binds defaults from CsafSchemaOptions. Override per tenant via excititor.formats.csaf:* keys:

excititor.formats.csaf:
  supportedVersions: ["2.0", "2.1"]   # CSAF version allowlist
  maxProductTreeDepth: 8              # DoS bound on product_tree.branches recursion
  collectAllIssues: false             # default fail-fast

Issue codes (stable wire contract — do not rename): csaf-schema-version-unsupported, csaf-missing-mandatory-key, csaf-cve-malformed, csaf-product-tree-too-deep, csaf-malformed-json.

The OASIS CSAF 2.0 schemas are embedded as resources in StellaOps.Excititor.Formats.CSAF.dll (vendored from docs/contracts/schemas/eu/csaf/2.0/). Air-gap installs and CI need no network access; runtime never fetches schemas.

Operator checklist (sprint 022 additions)

  1. Confirm excititor.formats.csaf:supportedVersions matches the issuers in your feed list. Restricting the allowlist to ["2.0"] (omitting 2.1) is supported and explicitly tested.
  2. Subscribe alerting rules to the new diagnostic family: csaf.validation.issueCode{value=csaf-missing-mandatory-key|csaf-schema-version-unsupported|csaf-cve-malformed|csaf-product-tree-too-deep|csaf-malformed-json}.
  3. Confirm ICsafSchemaValidator is registered in the connector host. The worker does this through AddCsafNormalizer(), which also registers the schema validator.

Sprint 20260502-026 addendum - quarantine sink

The quarantine sink is now implemented:

The row schema stores payload bytea, digest, reason, and diagnostics jsonb; operators should use the runbook instead of directly copying payloads into the raw store.