feat(signature): observe signing posture for every image — signed / invalid / not-signed (JEF-261)#135
Merged
thejefflarson merged 1 commit intoJun 30, 2026
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
SignaturePolicyonly opines onPROTECTOR_GATED_PREFIXES, and everything else (i.e. every image when gating is unconfigured, the default) wasNotApplicable, 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 transientchecking:CertificateSubject::Emailis recorded as a legitimate signer (today's org gate rejects it).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_layersreturns the layers; a layer'scertificate_signatureisSomeonly when the cert chains to Fulcio and its Rekor bundle verifies, so a present-but-untrusted signature comes back as a layer withcertificate_signature: None— which is exactly what distinguishes invalid from not-signed.signature.rssplit (done first, 1,000-line cap)engine/src/policies/signature.rs(~752 lines) →engine/src/policies/signature/:cosign.rs—CosignChecker. Fetches the signature layers once and serves bothobserve()(classify) andis_signed()(apply the org identity+issuer constraint), sharing the one registry round-trip. The gatedis_signedpath stays behavior-identical.posture.rs—SigningPosture+Signervalue types, theSignatureObservertrait, the in-memory per-passPostureMap, andSigningObserver(the TTL + image-keyed cache +PROTECTOR_MAX_IMAGEScap fronting the observer — re-observing a cached image adds zero outbound calls;checkingis 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 fromrun_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 sharedPolicyDecisionLogthe webhook also writes. TheSigningObserveris 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
SigningPosturefor 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
PROTECTOR_MAX_IMAGES. Graph/evidence never leave.allow; posture is the security-bearing fact, never an admit/deny gate.PolicyDecisionRecordcontract (already enforced at render).Tests (21 new)
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.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— cleancargo build— cleancargo clippy --all-targets(warnings = errors) — cleancargo test— 383 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
observeignores the identity regex; the engine'sbuild_signing_observerpasses a match-nothing pattern ($^) purely to satisfyCosignChecker::new, asserting no trusted signer. Documented at the call site.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 existingPolicyDecisionLog(subject, image, decision)logic.PolicyDecisionRecord.signaturefree-text field (new wordssigned/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