Naar de hoofdinhoud

Image-handtekeningen: ondertekenen met cosign, afdwingen bij pull

fremforge controleert de cosign-handtekening van elk containerimage dat je organisatie van frem.sh ophaalt. Jij bepaalt wiens handtekeningen je vertrouwt, en of een pull zonder handtekening wordt geregistreerd (shadow) of geweigerd (enforce).

De controle draait op de exacte digest van het image dat wordt geserveerd, op het moment van de pull, en geldt dus evengoed voor docker pull, Kubernetes, CI-jobs en elke andere OCI-client.

Een image ondertekenen in fremforge Actions

fremforge draait een eigen Sigstore: een certificaatautoriteit (Fulcio) op sign.frem.sh en een RFC 3161-tijdstempelautoriteit op tsa.frem.sh. Er draait geen transparantielog (geen Rekor) en geen certificaattransparantielog: het tijdstempel van tsa.frem.sh bewijst wanneer het image is ondertekend.

De trust root

Het rootcertificaat van sign.frem.sh en de certificaatketen van tsa.frem.sh zijn gepubliceerd op https://www.frem.sh/.well-known/slsa-trust-root.json, als keyless.fulcio_root_ca_pem en keyless.tsa_cert_chain_pem. Haal ze één keer op en commit ze naar de repository die ondertekent of verifieert: een gehoste runner bereikt sign.frem.sh en tsa.frem.sh, maar niet www.frem.sh.

curl -sSf https://www.frem.sh/.well-known/slsa-trust-root.json -o slsa-trust-root.json
jq -r .keyless.fulcio_root_ca_pem slsa-trust-root.json > fulcio-root.pem
jq -r .keyless.tsa_cert_chain_pem slsa-trust-root.json > tsa-chain.pem

Het subject van de root is CN=fremforge sigstore Root CA, O=fremverk, C=DE.

Een job ondertekent met zijn eigen kortlevende identiteit, dus er is geen ondertekeningssleutel om te bewaren of te roteren. Vraag het OIDC-endpoint van de runner om een token met de audience sigstore. Het certificaat noemt dan je workflow:

https://frem.sh/<org>/<repo>/.forgejo/workflows/<file>@<ref>

met de issuer https://frem.sh/_app/runner/oidc. De workflow heeft op het hoogste niveau enable-openid-connect: true nodig (Forgejo leidt dat niet af uit permissions) en de job id-token: write:

enable-openid-connect: true

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Sign the image
        env:
          IMAGE: frem.sh/acme/api@${{ env.DIGEST }}
          TOKEN: ${{ secrets.FORGEJO_TOKEN }}
        run: |
          set -euo pipefail
          ID_TOKEN=$(curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sigstore" | jq -r .value)

          # fulcio-root.pem and tsa-chain.pem: committed from the published
          # trust root (see "The trust root" above).
          cosign trusted-root create \
            --fulcio "url=https://sign.frem.sh,certificate-chain=fulcio-root.pem" \
            --tsa "url=https://tsa.frem.sh/api/v1/timestamp,certificate-chain=tsa-chain.pem" \
            --out trusted-root.json
          cosign signing-config create \
            --fulcio="url=https://sign.frem.sh,api-version=1,start-time=2026-01-01T00:00:00Z,operator=frem.sh" \
            --tsa="url=https://tsa.frem.sh/api/v1/timestamp,api-version=1,start-time=2026-01-01T00:00:00Z,operator=frem.sh" \
            --tsa-config="EXACT:1" --out signing-config.json

          echo "$TOKEN" | cosign login frem.sh -u ci --password-stdin
          cosign sign --yes --signing-config signing-config.json \
            --trusted-root trusted-root.json --identity-token "$ID_TOKEN" "$IMAGE"

cosign is geïnstalleerd in het image van de gehoste runner. Onderteken de digest (image@sha256:…), niet een tag: de handtekening is aan de digest gebonden.

Een handtekening verifiëren

Bouw trusted-root.json met hetzelfde commando cosign trusted-root create en noem dan de exacte workflow die moet hebben ondertekend:

cosign verify --trusted-root trusted-root.json \
  --use-signed-timestamps --insecure-ignore-tlog --insecure-ignore-sct \
  --certificate-oidc-issuer https://frem.sh/_app/runner/oidc \
  --certificate-identity https://frem.sh/acme/api/.forgejo/workflows/release.yaml@refs/heads/main \
  frem.sh/acme/api@sha256:…
  • --certificate-oidc-issuer is voor een job in fremforge Actions altijd https://frem.sh/_app/runner/oidc.
  • --certificate-identity is https://frem.sh/<org>/<repo>/.forgejo/workflows/<file>@<ref>, met als <ref> de volledige ref waarop de job draaide (refs/heads/main, refs/tags/v1.2.0). Om elke tag te accepteren gebruik je --certificate-identity-regexp '^https://frem\.sh/acme/api/\.forgejo/workflows/release\.yaml@refs/tags/v.+$'.
  • --use-signed-timestamps haalt het ondertekeningstijdstip uit het tijdstempel van tsa.frem.sh, en --insecure-ignore-tlog slaat de controle van de transparantielog over, omdat er geen Rekor is.
  • --insecure-ignore-sct is nodig omdat er geen certificaattransparantielog is, zodat de certificaten geen signed certificate timestamp (SCT) bevatten. Wat dat kost: er is geen openbare log waarin een ten onrechte uitgegeven certificaat zou opduiken. Het certificaat moet nog steeds naar de fremforge-root hierboven leiden, precies de issuer en identiteit bevatten die je noemt, en geldig zijn op het moment dat tsa.frem.sh ondertekende.

Andere manieren van ondertekenen werken ook:

  • Een sleutelpaar dat je zelf beheert: cosign sign --key cosign.key. Voeg de publieke sleutel (cosign.pub) toe aan het vertrouwensbeleid.
  • Sleutelloos vanuit GitHub Actions (publieke Sigstore): zet Ook de publieke Sigstore vertrouwen (sleutelloze handtekeningen uit GitHub Actions), offline geverifieerd aan. Dat wordt offline geverifieerd tegen een vastgepinde trust root; er wordt niets naar sigstore.dev gestuurd als een image wordt opgehaald.

Beide cosign-formaten worden gelezen: het standaard bundelformaat van cosign v3 (opgeslagen onder de tag sha256-<digest>, omdat de registry geen referrers-API heeft) en het oudere formaat .sig.

Het vertrouwensbeleid

Stel het in onder Beveiliging › Beleid › Imagebeleid › Image-handtekeningen bij pull, met de API, of met Terraform of OpenTofu.

InstellingBetekenis
enabledHandtekeningen controleren als een image wordt opgehaald.
modeshadow (de standaard): het image serveren en een zou-blokkeren registreren. enforce: de pull weigeren met 403.
signersVertrouwde sleutelloze ondertekenaars, in één lijst. Elk is een issuer alleen, die elk subject vertrouwt dat die issuer certificeert, of een issuer met een subject_regex waaraan het hele subject van het certificaat moet voldoen.
keysVertrouwde publieke sleutels: ECDSA P-256 of P-384, Ed25519, of RSA van minstens 2048 bits.
scopeOp welke images het van toepassing is: imagenamen in je organisatie, eventueel met een tag (api, team/*, api:prod-*). Leeg betekent elk image.
trust_public_sigstoreOok handtekeningen vertrouwen die met de publieke Sigstore zijn gemaakt.

Een pull slaagt als ten minste één handtekening van de geserveerde digest verifieert en de ondertekenaar ervan wordt vertrouwd: een van je sleutels, of een certificaat dat overeenkomt met een ondertekenaar. Zijn signers en keys allebei leeg, dan worden alleen de jobs in fremforge Actions van je eigen organisatie vertrouwd: een certificaat van de Sigstore van fremforge met de issuer https://frem.sh/_app/runner/oidc en een subject dat begint met https://frem.sh/<your org>/. Noem zo snel mogelijk de workflows die je bedoelt.

Een scope op tag geldt ook voor pulls op digest. Een digest noemt geen tag, dus een item als api:prod-* geldt ook voor elke pull van api@sha256:…. Anders zou een pull op digest een manier zijn om het te omzeilen.

In de console is Vertrouwde ondertekenaars (sleutelloos) één tekstveld: één ondertekenaar per regel, de URL van de issuer, eventueel gevolgd door een spatie en de reguliere expressie voor het subject. Een issuer alleen vertrouwt alles wat hij certificeert, dus voeg voor een openbare issuer zoals GitHub Actions of fremforge Actions altijd een subject toe. Een lijst die de pagina weigert, komt terug met de reden en wat je hebt ingevuld, en er wordt niets opgeslagen.

Een reguliere expressie voor het subject mag geen groep herhalen die zelf herhaalt (bijvoorbeeld (a+)+), en mag geen terugverwijzingen of lookaround gebruiken: zulke patronen kunnen exponentieel veel tijd kosten en worden bij het opslaan geweigerd.

Wat een pull krijgt

De digest…shadowenforce
heeft een handtekening van een vertrouwde ondertekenaargeserveerdgeserveerd
is niet ondertekendgeserveerd, registry.pull.would_block403 DENIED, registry.pull.blocked
is ondertekend, maar niet door een vertrouwde ondertekenaargeserveerd, zou-blokkeren403, geblokkeerd
heeft een handtekening die niet verifieert, of die een ander image noemtgeserveerd, zou-blokkeren403, geblokkeerd
kan niet worden gecontroleerd (de registry antwoordde niet)geserveerd, gelogd503 met Retry-After

De 403 zegt welk geval het was, noemt de ondertekenaar als die er is, en linkt naar de instellingenpagina.

Twee soorten manifesten worden niet gecontroleerd: de handtekeningen, attestaties en SBOM’s die aan images zijn gekoppeld (zodat cosign verify zelf blijft werken), en andere OCI-artefacten zoals Helm-charts. Dat wordt bepaald aan de hand van het manifest zelf, niet van zijn tag.

Images voor meerdere platformen. cosign ondertekent normaal gesproken de image-index. Slaagt een index, dan slagen ook zijn platformmanifesten, zodat de daaropvolgende pull van linux/amd64 op digest wordt geserveerd.

Een geverifieerd resultaat wordt een uur hergebruikt, en een mislukt resultaat een minuut, dus een handtekening die je na een weigering pusht, telt binnen een minuut mee. Een wijziging van het vertrouwensbeleid geldt direct.

Uitrollen

  1. Zet de controle aan in shadow en geef haar een paar dagen.
  2. Lees de zou-blokkeren-gebeurtenissen in de auditlog (registry.pull.would_block met gate: signature) of onder Recente handtekeningcontroles op de instellingenpagina.
  3. Onderteken wat ontbreekt, maak scope zo nodig kleiner en schakel dan over naar enforce.

API

GET en PUT /orgs/{org}/image-signature-policy op https://frem.sh/_app/api/v1, met policy:read en policy:write. PUT vervangt het hele beleid: een veld dat je weglaat, krijgt zijn standaardwaarde.

Tot 2026-10-16 waren vertrouwde ondertekenaars twee velden: issuers (een issuer met elk subject) en identities (een issuer met een reguliere expressie voor het subject). Beide werken nog en GET geeft ze nog terug, naast signers. Een PUT mag een van beide vormen sturen, of alle drie als ze overeenkomen, zodat lezen-wijzigen-schrijven met de oude velden blijft werken. Velden die elkaar tegenspreken, worden geweigerd met 400 invalid-value.

curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  "https://frem.sh/_app/api/v1/orgs/acme/image-signature-policy" \
  -d '{"enabled": true, "mode": "shadow",
       "signers": [{"issuer": "https://frem.sh/_app/runner/oidc",
                    "subject_regex": "https://frem\\.sh/acme/[^/]+/\\.forgejo/workflows/release\\.yaml@refs/tags/v.+"}],
       "scope": ["api", "worker:prod-*"]}'

Een ongeldig beleid wordt geweigerd met 400 invalid-value, met de naam van het veld.

Audit en SIEM

  • Elke wijziging: security_policy.image_signature.updated, met wat er veranderde.
  • Elke beslissing: registry.pull.blocked of registry.pull.would_block met gate: signature en reason (unsigned, untrusted of invalid), hooguit één keer per uur per image-digest, met het aantal van het vorige uur. Deze bereiken je SIEM-stroom zoals elke andere auditgebeurtenis, en een blokkering activeert ook de webhook voor beveiligingsmeldingen security.pull.blocked.

Handtekeningbevindingen in pull requests

Bij elke pull request controleert fremforge de handtekeningen van de containerimages die je repository op digest vastpint, dat wil zeggen een regel image: die eindigt op @sha256:…. De controle kijkt in YAML-bestanden, Helm-templates, Dockerfiles en Containerfiles, en dekt tot 50 images per scan. Het resultaat is de status fremforge/cosign-verify op de pull request en de kolom Cosign op de pagina Containerimages.

Elk image krijgt een van deze resultaten:

  • Ondertekend: een handtekening is geverifieerd, en de ondertekenaar wordt getoond. Twee soorten ondertekenaars worden vertrouwd:
    • de eigen workflows in fremforge Actions van je organisatie, ondertekend via de Sigstore van fremforge en gecontroleerd op het vertrouwde tijdstempel;
    • een ondertekenaar in de publieke Sigstore, gecontroleerd tegen diens transparantielog.
  • Niet ondertekend: de registry antwoordde en heeft geen handtekening van een vertrouwde ondertekenaar. Een handtekening van de workflow van een andere organisatie telt als niet ondertekend.
  • Geen vertrouwd tijdstempel: de eigen workflow van je organisatie ondertekende het image via de Sigstore van fremforge, maar zonder het vertrouwde tijdstempel. Zo’n handtekening kan niet meer worden gecontroleerd zodra haar kortlevende certificaat verloopt, dus ze telt als niet ondertekend. Onderteken opnieuw met de ondertekeningsconfiguratie uit Een image ondertekenen in fremforge Actions.
  • Niet te controleren: de controle kon de handtekening niet lezen. De pagina Containerimages toont de reden, bijvoorbeeld dat de registry niet via je dependency proxy wordt geserveerd, dat het image in de registry van een andere organisatie staat, of dat de registry niet antwoordde. Een resultaat niet te controleren wordt nooit gemeld als niet ondertekend.

De controle leest images via de dependency proxy van je organisatie, uit de frem.sh-registry van je organisatie, en uit een private registry waarvoor je organisatie inloggegevens heeft. Ze verifieert offline en neemt dus zelf nooit contact op met de registry van het image of met Sigstore. Beide cosign-formaten worden gelezen.

Images in een private registry

Een image in een registry buiten fremforge, bijvoorbeeld registry.gitlab.com/acme/tool@sha256:… of een private Harbor, wordt gecontroleerd als je organisatie alleen-lezen-inloggegevens voor de host van die registry heeft opgeslagen:

  • Zet het aan en voeg inloggegevens toe onder Beveiliging › Toegang › Registerinloggegevens (of via de API voor registerinloggegevens). Daarvoor is een eigenaar van de organisatie nodig, en de functie staat uit tot een eigenaar haar aanzet.
  • Eén set inloggegevens per host, kind: oci, met een verplichte vervaldatum van hooguit 366 dagen. Docker Hub, ghcr.io, quay.io en de registries van Red Hat staan niet op deze lijst: images daarvandaan gebruiken in plaats daarvan de upstream-inloggegevens van je organisatie in de dependency proxy.
  • De inloggegevens blijven binnen fremforge. De dependency proxy gebruikt ze om de handtekening van het image te lezen, en alleen voor een scan van je organisatie. De scanner heeft ze nooit in handen, ze worden nooit naar een andere host gestuurd, en niets wat ermee is gelezen, wordt in de cache gezet.

Kunnen de inloggegevens niet worden gebruikt, dan is het image Niet te controleren, met een van deze redenen:

  • verlopen: de inloggegevens zijn over hun vervaldatum heen. Een eigenaar wijzigt de vervaldatum of vervangt ze onder Registerinloggegevens;
  • geweigerd: de registry antwoordde met 401 of 403. Een eigenaar controleert de inloggegevens of vervangt ze onder Registerinloggegevens;
  • leeslimiet bereikt: de controle bereikte de leeslimiet van de inloggegevens. fremforge controleert het later opnieuw;
  • onbruikbaar: fremforge kon de opgeslagen inloggegevens niet gebruiken. De fout ligt bij fremforge, en fremforge controleert het later opnieuw.

Een registry waarvoor je organisatie geen inloggegevens heeft, blijft Niet te controleren, omdat de registry niet via je dependency proxy wordt geserveerd.

De status is informatief: hij slaagt altijd en blokkeert nooit een merge. De pullcontrole hierboven is een aparte controle.

Images die fremforge vanuit zijn eigen platformregistry serveert, tonen “ondertekend door fremforge” als de releasebuild van fremforge ze heeft ondertekend. Voor die images is die build de enige vertrouwde ondertekenaar, en hij wordt nooit vertrouwd voor je eigen images.

Zie ook