Zum Hauptinhalt springen

Image-Signaturen: mit cosign signieren, beim Pull durchsetzen

fremforge prüft die cosign-Signatur jedes Container-Images, das deine Organisation von frem.sh abruft. Du legst fest, wessen Signaturen du vertraust und ob ein Pull ohne Signatur protokolliert (shadow) oder abgewiesen (enforce) wird.

Die Prüfung läuft auf genau dem Image-Digest, der ausgeliefert wird, im Moment des Pulls. Sie gilt daher gleichermaßen für docker pull, Kubernetes, CI-Jobs und jeden anderen OCI-Client.

Ein Image in fremforge Actions signieren

fremforge betreibt einen eigenen Sigstore: eine Zertifizierungsstelle (Fulcio) unter sign.frem.sh und eine Zeitstempelstelle nach RFC 3161 unter tsa.frem.sh. Es gibt kein Transparenz-Log (kein Rekor) und kein Certificate-Transparency-Log: Der Zeitstempel von tsa.frem.sh belegt, wann das Image signiert wurde.

Die Vertrauensbasis

Das Root-Zertifikat von sign.frem.sh und die Zertifikatskette von tsa.frem.sh sind unter https://www.frem.sh/.well-known/slsa-trust-root.json als keyless.fulcio_root_ca_pem und keyless.tsa_cert_chain_pem veröffentlicht. Hole sie einmal ab und committe sie in das Repository, das signiert oder prüft: Ein gehosteter Runner erreicht sign.frem.sh und tsa.frem.sh, aber nicht 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

Das Subjekt der Root lautet CN=fremforge sigstore Root CA, O=fremverk, C=DE.

Ein Job signiert mit seiner eigenen kurzlebigen Identität, es gibt also keinen Signaturschlüssel, den du speichern oder rotieren musst. Fordere beim OIDC-Endpunkt des Runners ein Token mit der Audience sigstore an. Das Zertifikat nennt dann deinen Workflow:

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

mit dem Aussteller https://frem.sh/_app/runner/oidc. Der Workflow braucht auf oberster Ebene enable-openid-connect: true (Forgejo leitet das nicht aus permissions ab) und im 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 ist im Image des gehosteten Runners installiert. Signiere den Digest (image@sha256:…), nicht einen Tag: Die Signatur ist an den Digest gebunden.

Eine Signatur prüfen

Erzeuge trusted-root.json mit demselben Befehl cosign trusted-root create und nenne dann genau den Workflow, der signiert haben muss:

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 ist für einen Job in fremforge Actions immer https://frem.sh/_app/runner/oidc.
  • --certificate-identity ist https://frem.sh/<org>/<repo>/.forgejo/workflows/<file>@<ref>, wobei <ref> die vollständige Ref ist, auf der der Job lief (refs/heads/main, refs/tags/v1.2.0). Um jeden Tag zu akzeptieren, verwende --certificate-identity-regexp '^https://frem\.sh/acme/api/\.forgejo/workflows/release\.yaml@refs/tags/v.+$'.
  • --use-signed-timestamps nimmt den Signaturzeitpunkt aus dem Zeitstempel von tsa.frem.sh, und --insecure-ignore-tlog überspringt die Prüfung des Transparenz-Logs, weil es kein Rekor gibt.
  • --insecure-ignore-sct ist nötig, weil es kein Certificate-Transparency-Log gibt und die Zertifikate daher keinen Signed Certificate Timestamp (SCT) tragen. Was das kostet: Es gibt kein öffentliches Log, in dem ein fälschlich ausgestelltes Zertifikat auftauchen würde. Das Zertifikat muss trotzdem bis zur oben genannten fremforge-Root verkettet sein, genau den Aussteller und die Identität tragen, die du nennst, und zum Zeitpunkt der Signatur durch tsa.frem.sh gültig sein.

Andere Arten zu signieren funktionieren ebenfalls:

  • Ein Schlüsselpaar, das du selbst verwaltest: cosign sign --key cosign.key. Füge den öffentlichen Schlüssel (cosign.pub) der Vertrauensrichtlinie hinzu.
  • GitHub Actions schlüssellos (öffentlicher Sigstore): Schalte Auch dem öffentlichen Sigstore vertrauen ein. Die Prüfung erfolgt offline gegen eine fest hinterlegte Vertrauensbasis; beim Pull eines Images wird nichts an sigstore.dev gesendet.

Beide cosign-Formate werden gelesen: das Standard-Bundle-Format von cosign v3 (unter dem Tag sha256-<digest> abgelegt, weil die Registry keine Referrers-API hat) und das ältere .sig-Format.

Die Vertrauensrichtlinie

Lege sie unter Sicherheit › Richtlinien › Image-Richtlinien › Image-Signaturen beim Pull fest, über die API oder mit Terraform oder OpenTofu.

EinstellungBedeutung
enabledSignaturen prüfen, wenn ein Image abgerufen wird.
modeshadow (Standard): Das Image ausliefern und eine Would-Block protokollieren. enforce: Den Pull mit 403 abweisen.
signersVertrauenswürdige schlüssellose Signierende, eine Liste. Jeder Eintrag ist entweder ein issuer allein, der jedem Subjekt vertraut, das dieser Aussteller zertifiziert, oder ein issuer mit einer subject_regex, auf die das gesamte Zertifikatssubjekt passen muss.
keysVertrauenswürdige öffentliche Schlüssel: ECDSA P-256 oder P-384, Ed25519 oder RSA mit mindestens 2048 Bit.
scopeFür welche Images sie gilt: Image-Namen in deiner Organisation, optional mit einem Tag (api, team/*, api:prod-*). Leer bedeutet jedes Image.
trust_public_sigstoreAuch Signaturen vertrauen, die mit dem öffentlichen Sigstore erstellt wurden.

Ein Pull besteht, wenn mindestens eine Signatur des ausgelieferten Digests verifiziert wird und ihr Signierender vertrauenswürdig ist: einer deiner Schlüssel oder ein Zertifikat, das zu einem Signierenden passt. Sind signers und keys beide leer, wird nur den Jobs in fremforge Actions deiner eigenen Organisation vertraut: einem Zertifikat aus fremforges Sigstore mit dem Aussteller https://frem.sh/_app/runner/oidc und einem Subjekt, das mit https://frem.sh/<your org>/ beginnt. Nenne die gemeinten Workflows, sobald du kannst.

Ein Scope nach Tag gilt auch für Pulls per Digest. Ein Digest nennt keinen Tag, daher gilt ein Eintrag wie api:prod-* auch für jeden Pull von api@sha256:…. Andernfalls ließe sich die Regel mit einem Pull per Digest umgehen.

In der Konsole ist Vertrauenswürdige Signierende (schlüssellos) ein einziges Textfeld: ein Signierender pro Zeile, die Aussteller-URL, dann optional ein Leerzeichen und der reguläre Ausdruck für das Subjekt. Ein Aussteller allein vertraut allem, was er zertifiziert; gib daher bei einem öffentlichen Aussteller wie GitHub Actions oder fremforge Actions immer ein Subjekt an. Eine Liste, die die Seite ablehnt, kommt mit dem Grund und deiner Eingabe zurück, und es wird nichts gespeichert.

Ein regulärer Ausdruck für das Subjekt darf keine Gruppe wiederholen, die selbst wiederholt (zum Beispiel (a+)+), und darf keine Rückverweise oder Lookarounds verwenden: Solche Muster können exponentiell lange laufen und werden beim Speichern abgelehnt.

Was ein Pull erhält

Der Digest …shadowenforce
hat eine Signatur von einem vertrauenswürdigen Signierendenausgeliefertausgeliefert
ist nicht signiertausgeliefert, registry.pull.would_block403 DENIED, registry.pull.blocked
ist signiert, aber nicht von einem vertrauenswürdigen Signierendenausgeliefert, Would-Block403, blockiert
hat eine Signatur, die sich nicht verifizieren lässt oder ein anderes Image nenntausgeliefert, Would-Block403, blockiert
lässt sich nicht prüfen (die Registry hat nicht geantwortet)ausgeliefert, protokolliert503 mit Retry-After

Die 403-Antwort sagt, welcher Fall vorlag, nennt den Signierenden, falls es einen gibt, und verlinkt auf die Einstellungsseite.

Zwei Arten von Manifesten werden nicht geprüft: Signaturen, Attestierungen und SBOMs, die an Images angehängt sind (damit cosign verify selbst weiter funktioniert), und andere OCI-Artefakte wie Helm-Charts. Das wird am Manifest selbst entschieden, nicht an seinem Tag.

Multi-Plattform-Images. cosign signiert normalerweise den Image-Index. Besteht ein Index die Prüfung, bestehen auch seine Plattform-Manifeste, sodass der nachfolgende Pull von linux/amd64 per Digest ausgeliefert wird.

Ein verifiziertes Ergebnis wird eine Stunde lang wiederverwendet, ein fehlgeschlagenes eine Minute lang. Eine Signatur, die du nach einer Abweisung pushst, zählt also innerhalb einer Minute. Eine Änderung der Vertrauensrichtlinie wirkt sofort.

Einführen

  1. Schalte die Prüfung im Modus shadow ein und lass ihr ein paar Tage Zeit.
  2. Lies die Would-Blocks im Audit-Log (registry.pull.would_block mit gate: signature) oder unter Letzte Signaturprüfungen auf der Einstellungsseite.
  3. Signiere, was fehlt, grenze scope bei Bedarf ein und wechsle dann zu enforce.

API

GET und PUT /orgs/{org}/image-signature-policy unter https://frem.sh/_app/api/v1, mit policy:read und policy:write. PUT ersetzt die gesamte Richtlinie: Ein Feld, das du weglässt, erhält seinen Standardwert.

Bis zum 2026-10-16 bestanden die vertrauenswürdigen Signierenden aus zwei Feldern, issuers (ein Aussteller mit beliebigem Subjekt) und identities (ein Aussteller mit einem regulären Ausdruck für das Subjekt). Beide funktionieren weiterhin, und GET gibt sie neben signers weiterhin zurück. Ein PUT kann jede der beiden Formen senden oder alle drei, sofern sie übereinstimmen, sodass ein Read-Modify-Write der alten Felder weiter funktioniert. Felder, die sich widersprechen, werden mit 400 invalid-value abgelehnt.

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-*"]}'

Eine ungültige Richtlinie wird mit 400 invalid-value abgelehnt, unter Angabe des Feldes.

Audit und SIEM

  • Jede Änderung: security_policy.image_signature.updated, mit dem, was sich geändert hat.
  • Jede Entscheidung: registry.pull.blocked oder registry.pull.would_block mit gate: signature und reason (unsigned, untrusted oder invalid), höchstens einmal pro Stunde je Image-Digest, mit der Anzahl der vorangegangenen Stunde. Diese Ereignisse erreichen deinen SIEM-Stream wie jedes andere Audit-Ereignis, und eine Blockierung löst zusätzlich den Alert-Webhook security.pull.blocked aus.

Signaturbefunde in Pull Requests

Bei jedem Pull Request prüft fremforge die Signaturen der Container-Images, die dein Repository per Digest festlegt, also eine image:-Zeile, die auf @sha256:… endet. Die Prüfung sucht in YAML-Dateien, Helm-Templates, Dockerfiles und Containerfiles und deckt bis zu 50 Images pro Scan ab. Das Ergebnis ist der Status fremforge/cosign-verify am Pull Request und die Spalte Cosign auf der Seite Container-Images.

Jedes Image erhält eines dieser Ergebnisse:

  • Signiert: Eine Signatur wurde verifiziert, und der Signierende wird angezeigt. Zwei Arten von Signierenden gelten als vertrauenswürdig:
    • die eigenen Workflows deiner Organisation in fremforge Actions, signiert über fremforges Sigstore und anhand des vertrauenswürdigen Zeitstempels geprüft;
    • ein Signierender im öffentlichen Sigstore, geprüft gegen dessen Transparenz-Log.
  • Nicht signiert: Die Registry hat geantwortet und enthält keine Signatur von einem vertrauenswürdigen Signierenden. Eine Signatur, die der Workflow einer anderen Organisation erstellt hat, zählt als nicht signiert.
  • Kein vertrauenswürdiger Zeitstempel: Der eigene Workflow deiner Organisation hat das Image über fremforges Sigstore signiert, aber ohne den vertrauenswürdigen Zeitstempel. Eine solche Signatur lässt sich nach Ablauf ihres kurzlebigen Zertifikats nicht mehr prüfen und zählt daher als nicht signiert. Signiere erneut mit der Signaturkonfiguration aus Ein Image in fremforge Actions signieren.
  • Konnte nicht prüfen: Die Prüfung konnte die Signatur nicht lesen. Die Seite Container-Images zeigt den Grund, zum Beispiel dass die Registry nicht von deinem Dependency-Proxy bedient wird, dass das Image in der Registry einer anderen Organisation liegt oder dass die Registry nicht geantwortet hat. Ein Ergebnis konnte nicht prüfen wird nie als nicht signiert gemeldet.

Die Prüfung liest Images über den Dependency-Proxy deiner Organisation, aus der frem.sh-Registry deiner Organisation und aus einer privaten Registry, für die deine Organisation Zugangsdaten hinterlegt hat. Sie prüft offline und kontaktiert daher nie selbst die Registry des Images oder Sigstore. Beide cosign-Formate werden gelesen.

Images in einer privaten Registry

Ein Image in einer Registry außerhalb von fremforge, zum Beispiel registry.gitlab.com/acme/tool@sha256:… oder ein privater Harbor, wird geprüft, wenn deine Organisation Zugangsdaten mit Lesezugriff für diesen Registry-Host hinterlegt hat:

  • Schalte es ein und füge Zugangsdaten hinzu unter Sicherheit › Zugriff › Registry-Zugangsdaten (oder über die API für Registry-Zugangsdaten). Dafür ist ein Owner der Organisation nötig, und die Funktion ist ausgeschaltet, bis ein Owner sie einschaltet.
  • Ein Satz Zugangsdaten pro Host, kind: oci, mit einem verpflichtenden Ablaufdatum von höchstens 366 Tagen. Docker Hub, ghcr.io, quay.io und die Registrys von Red Hat stehen nicht auf dieser Liste: Images von dort verwenden stattdessen die Upstream-Zugangsdaten deiner Organisation im Dependency-Proxy.
  • Die Zugangsdaten bleiben innerhalb von fremforge. Der Dependency-Proxy verwendet sie, um die Signatur des Images zu lesen, und nur für einen Scan deiner Organisation. Der Scanner hält sie nie, sie werden nie an einen anderen Host gesendet, und nichts, was damit gelesen wird, wird zwischengespeichert.

Wenn sich die Zugangsdaten nicht verwenden lassen, lautet das Ergebnis des Images Konnte nicht prüfen, mit einem dieser Gründe:

  • abgelaufen: Die Zugangsdaten haben ihr Ablaufdatum überschritten. Ein Owner ändert das Ablaufdatum oder ersetzt sie unter Registry-Zugangsdaten;
  • abgewiesen: Die Registry hat mit 401 oder 403 geantwortet. Ein Owner prüft die Zugangsdaten oder ersetzt sie unter Registry-Zugangsdaten;
  • Ratenlimit erreicht: Die Prüfung hat das Leselimit der Zugangsdaten erreicht. fremforge prüft später erneut;
  • unbrauchbar: fremforge konnte die hinterlegten Zugangsdaten nicht verwenden. Der Fehler liegt bei fremforge, und es prüft später erneut.

Eine Registry, für die deine Organisation keine Zugangsdaten hinterlegt hat, bleibt bei Konnte nicht prüfen, weil die Registry nicht von deinem Dependency-Proxy bedient wird.

Der Status ist informativ: Er besteht immer und blockiert nie einen Merge. Die oben beschriebene Pull-Prüfung ist eine eigene Prüfung.

Images, die fremforge aus seiner eigenen Plattform-Registry ausliefert, zeigen „signed by fremforge“, wenn der Release-Build von fremforge sie signiert hat. Für diese Images ist dieser Build der einzige vertrauenswürdige Signierende, und ihm wird für deine eigenen Images nie vertraut.

Siehe auch