Skip to content

feat(signature): observe signing posture for every image — signed / invalid / not-signed (JEF-261)#135

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-261-observe-signing-posture-signed-invalid-not-signed-for-every
Jun 30, 2026
Merged

feat(signature): observe signing posture for every image — signed / invalid / not-signed (JEF-261)#135
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-261-observe-signing-posture-signed-invalid-not-signed-for-every

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-261.

ADR-0020 Stage 1 (INVENTORY) — the foundational ticket the rest of the signing-continuity sprint builds on. The operator could not see the cluster's signing posture: SignaturePolicy only opines on PROTECTOR_GATED_PREFIXES, and everything else (i.e. every image when gating is unconfigured, the default) was NotApplicable, which reads as a green stamp.

What changed

A signing observer reads any image's posture by observation — no gated_prefixes, no trusted-identity config — into one of three definitive resting states (never n/a), plus a transient checking:

  • signed — signature present and verifies (chains to the public-good Fulcio root + Rekor bundle). Also captures the signer identity + OIDC issuer from the Fulcio cert subject. CertificateSubject::Email is recorded as a legitimate signer (today's org gate rejects it).
  • invalid-signature — a signature artifact is present but does NOT verify (broken chain / tampered / untrusted root). Distinct from, and more alarming than, not-signed.
  • not-signed — no signature at all.
  • checking — transient registry/Rekor-unreachable; resolves into one of the three on a later pass — never a resting posture, never a fabricated posture, never a false clean.

The Fulcio/Rekor chain is the trust anchor (not a caller identity), so we learn who signed for any image with nothing configured. This matches the sigstore-rs API: trusted_signature_layers returns the layers; a layer's certificate_signature is Some only when the cert chains to Fulcio and its Rekor bundle verifies, so a present-but-untrusted signature comes back as a layer with certificate_signature: None — which is exactly what distinguishes invalid from not-signed.

signature.rs split (done first, 1,000-line cap)

engine/src/policies/signature.rs (~752 lines) → engine/src/policies/signature/:

  • cosign.rsCosignChecker. Fetches the signature layers once and serves both observe() (classify) and is_signed() (apply the org identity+issuer constraint), sharing the one registry round-trip. The gated is_signed path stays behavior-identical.
  • posture.rsSigningPosture + Signer value types, the SignatureObserver trait, the in-memory per-pass PostureMap, and SigningObserver (the TTL + image-keyed cache + PROTECTOR_MAX_IMAGES cap fronting the observer — re-observing a cached image adds zero outbound calls; checking is never cached, so a blip is retried).
  • tests.rs — the existing policy tests + the new posture/observer tests.

Where the sweep wires in

engine/src/engine/signing_sweep.rs (driven from run_loop) sweeps every distinct image in the analysis engine's Pod reflector store each pass, so already-running pods (not just new admissions) are observed, recording each posture into the shared PolicyDecisionLog the webhook also writes. The SigningObserver is built once so its cache persists across passes (steady cluster re-sweeps for free); a no-op (zero outbound calls) when no observer is configured.

Scope discipline

Observation + recording only. Exposes SigningPosture for the rest of the sprint to consume — does NOT build the Admission render (JEF-262), the TOFU baseline (JEF-263), drift findings (JEF-264), enforcement (JEF-265), or Rekor history (JEF-266). State is in-memory; no durable schema yet (JEF-263).

Invariants

  • Zero-egress (ADR-0015 carve-out): uses the already-sanctioned path (registry the cluster pulls from + sigstore TUF/Rekor), per distinct image, bounded by the cache + PROTECTOR_MAX_IMAGES. Graph/evidence never leave.
  • Presentation is a view, never a gate (ADR-0016): sweep rows are recorded with decision allow; posture is the security-bearing fact, never an admit/deny gate.
  • Untrusted text escaped at render: signer identity/issuer are flagged as untrusted in the PolicyDecisionRecord contract (already enforced at render).

Tests (21 new)

  • posture/observer (signature::tests): signed-with-identity-and-issuer (no regex configured), invalid-signature distinct from not-signed, not-signed, transient-error-is-checking, checking-not-cached-and-retried, cached-resting-adds-zero-calls, sweep dedup/cap/cache-reuse, last-write-wins, Email-as-legitimate-signer.
  • sweep (signing_sweep::tests): records postures for running pods, checking recorded not dropped, no-observer no-op, re-sweep cache reuse.

Gate results (in engine/)

  • cargo fmt --check — clean
  • cargo build — clean
  • cargo clippy --all-targets (warnings = errors) — clean
  • cargo test383 lib + 1 bin + 9 dashboard + 1 file-size-guard + 0 doc passed, 0 failed (file-size guard confirms every split file is under the 1,000-line cap)

Decisions made autonomously

  • Engine observer needs no identity config. observe ignores the identity regex; the engine's build_signing_observer passes a match-nothing pattern ($^) purely to satisfy CosignChecker::new, asserting no trusted signer. Documented at the call site.
  • Sweep rows are keyed by Image/<ref> (not per-workload). A digest is shared across replicas/workloads, and per-workload attribution is JEF-262's render concern; Stage-1 recording keys by image, deduped by the existing PolicyDecisionLog (subject, image, decision) logic.
  • Posture is recorded in the existing PolicyDecisionRecord.signature free-text field (new words signed/invalid-signature/not-signed/checking) rather than a new durable schema — the ticket scopes durable state to JEF-263. The webhook's JEF-246 shadow words still flow on their own rows.

🤖 Generated with Claude Code

…nvalid / not-signed (JEF-261)

ADR-0020 Stage 1 (INVENTORY): read any image's signing posture by observation,
with NO gated_prefixes and NO trusted-identity config, into one of three definitive
resting states (never n/a) plus a transient "checking":

  - signed        — signature present and verifies (chains to the public-good Fulcio
                    root + Rekor bundle); also captures signer identity + OIDC issuer
                    from the Fulcio cert subject (Email recorded as a legitimate signer,
                    which today's org gate rejects).
  - invalid-sig   — a signature artifact is present but does NOT verify (broken chain /
                    tampered / untrusted root). Distinct from, and more alarming than,
                    not-signed.
  - not-signed    — no signature at all.
  - checking      — transient registry/Rekor-unreachable; resolves into one of the three
                    on a later pass, never a resting posture, never a false clean.

The Fulcio/Rekor chain is the trust anchor (not a caller identity), so we learn who
signed for any image with nothing configured.

signature.rs (~752 lines) is split into a module dir BEFORE extending (1,000-line cap):
  - cosign.rs   — CosignChecker. Now fetches the signature layers ONCE and serves both
                  observe() (classify the layers) and is_signed() (apply the org
                  identity+issuer constraint), sharing the one registry round-trip. The
                  gated is_signed path stays behavior-identical.
  - posture.rs  — SigningPosture + Signer value types, the SignatureObserver trait, the
                  in-memory per-pass PostureMap, and SigningObserver (the TTL + image-keyed
                  cache + PROTECTOR_MAX_IMAGES cap fronting the observer, so re-observing a
                  cached image adds zero outbound calls).
  - tests.rs    — the existing policy tests + the new posture/observer tests.

Already-running pods (not just new admissions) are observed via a per-pass sweep over
the analysis engine's Pod reflector store (engine/signing_sweep.rs, driven from
run_loop), recording each distinct running image's posture into the shared
PolicyDecisionLog the webhook also writes. Bounded by the observer cache + MAX_IMAGES,
so a steady cluster re-sweeps for free; a no-op (zero outbound calls) when no observer
is configured.

Zero-egress preserved: uses the ADR-0015-sanctioned path (the registry the cluster
already pulls from + sigstore TUF/Rekor), per distinct image, bounded by cache +
MAX_IMAGES. Signer-identity text is untrusted and flagged for escaping at render. Scope
is observation + recording only — the Admission render (JEF-262), the TOFU baseline
(JEF-263), drift (JEF-264), enforcement (JEF-265), and Rekor history (JEF-266) consume
SigningPosture; they are NOT built here. State is in-memory; no durable schema yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson thejefflarson merged commit 280e874 into main Jun 30, 2026
1 of 3 checks passed
@thejefflarson thejefflarson deleted the thejefflarson/jef-261-observe-signing-posture-signed-invalid-not-signed-for-every branch June 30, 2026 04:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant