Security model¶
gapsign sits on the supply-chain trust boundary: it signs images and drives the admission decision that keeps unsigned or untrusted images out of protected namespaces. Every phase with attack surface went through a staged cadence — audit → fix → verify → empirical exploit proof → regression-pin → red-team rounds — before it shipped.
Design invariants¶
- Sign the digest, not the tag. Signatures commit to
@sha256:…; admission resolves tag→digest and verifies the digest. A re-tag cannot bypass a signature. - Fail closed. An unsigned image, a missing key, an unverifiable signature, an
unmatched default-deny rule, or an un-renderable policy all refuse — never
admit-by-default. A rendered policy uses
failurePolicy: Fail. - Repository-scoped. Verification binds the digest and the repository, so a valid signature transplanted to another repo (same content) is rejected.
- Airgap invariant. The sign and verify paths never contact Fulcio, Rekor, a
public OIDC provider, or a CT log — enforced by construction (there is no such
dependency) and by
rekor.ignoreTlog/ctlog.ignoreSCTin the rendered policy. - No default or hardcoded secrets. Signing keys resolve from a file under
~/.config/gapsign/(0600) or a KMS; only public trust material is ever embedded or committed. Encrypted (legacy) private keys are refused.
What each mode hardens¶
- Mirror ingest — a mandatory, host-boundary source allowlist (SSRF guard, enforced before any network call); a bounded source footprint (child-count and index-depth caps, unaccountable child media types refused); digest-pinned copy with a post-copy digest assertion; default-deny signer authorization. No shell subprocess, so no argument/option-injection surface.
- Admission render — a private-key-leak guard (a
ca_rootsor public-key field that is not what it claims is refused, including a public-then-private concatenation); DNS-1123 validation ofmetadata.name, namespaces, and image globs so an invalid value can't produce a policy the API server rejects on apply (a fail-open); no code path emits an empty attestor,required: false, orfailurePolicy != Fail. - X.509 mode — trust roots come only from configuration, never the signature; the leaf must chain to the CA as a code-signing certificate; identity is matched against SANs (exact), and the Sigstore issuer extension must be present exactly once and non-conflicting across v1/v2.
Verification-first¶
Interoperability and enforcement are proven, not asserted: an integration suite signs against a live registry and verifies with the real cosign CLI and the real Kyverno CLI (key and X.509 modes), offline — a signed image is admitted and an unsigned or wrong-identity image is blocked. These run in CI on every push.
Honesty rule — residual risk¶
No audit eliminates every risk. What remains, inherently: trust in the operator-supplied CA roots and keys, the Go standard library and go-containerregistry dependencies, the OS/kernel, and CA hygiene (anyone holding the corporate CA key can mint a matching leaf, by PKI design). Within the code, every discovered vector is closed and regression-pinned. The detailed residuals ledger is kept local (gitignored) and is never published.
Reporting¶
Email amit.patole@gmail.com with a description and reproduction. Please do not open a public issue for an unfixed vulnerability.