Skip to content

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.ignoreSCT in 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_roots or public-key field that is not what it claims is refused, including a public-then-private concatenation); DNS-1123 validation of metadata.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, or failurePolicy != 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.