Zum Hauptinhalt springen

Unveränderliche Releases und Release-Attestierung

Ein veröffentlichtes Release ist ein Versprechen: diese Dateien, gebaut aus diesem Commit, unter diesem Tag. Ohne Unveränderlichkeit lässt sich dieses Versprechen nachträglich still und leise brechen: ein Tag, der auf einen anderen Commit umgebogen wird, ein Asset, das gegen ein anderes Binary getauscht wird, ein Release, das gelöscht und unter demselben Namen neu angelegt wird. Unveränderliche Releases machen all das unmöglich, und mit einer Release-Attestierung kann jede und jeder auf dem eigenen Rechner prüfen, dass das Heruntergeladene dem Veröffentlichten entspricht.

Was eingefroren wird

Sind unveränderliche Releases eingeschaltet, wird ein Release im Moment der Veröffentlichung eingefroren. Ab dann gilt:

ÄnderungErlaubt?
Titel, Release Notes, Pre-Release-Kennzeichen bearbeitenJa
Tag-Namen oder Ziel-Commit ändernNein
Den Tag mit git push --force verschieben oder mit git push --delete löschenNein
Das Release wieder in einen Entwurf umwandelnNein
Ein Asset hinzufügen, umbenennen, ersetzen oder löschenNein
Das Release löschenJa, aber sein Tag-Name kann in diesem Repository nie wieder verwendet werden

Jede Oberfläche setzt dieselbe Regel durch: die Weboberfläche, die REST-API (HTTP 422 mit einer Meldung, die mit immutable release: beginnt) und Git selbst (der Push wird vom Pre-Receive-Hook mit einer Meldung abgelehnt, die den Tag nennt).

Das Einfrieren wirkt nur nach vorn. Das Einschalten der Einstellung friert nichts ein, was bereits veröffentlicht war, und ein späteres Ausschalten taut nichts auf. Muss ein eingefrorenes Release wirklich geändert werden, wende dich an den Support: Der Support kann das Einfrieren dieses einen Releases aufheben, mit einer schriftlichen Begründung, die in deinem Audit-Log erscheint.

Assets vor dem Release veröffentlichen

Ein veröffentlichtes unveränderliches Release nimmt keine neuen Assets an, daher ist die Reihenfolge wichtig. Lege das Release als Entwurf an, lade die Assets hoch und veröffentliche es dann:

# 1. draft
curl -sS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"tag_name":"v1.2.3","name":"v1.2.3","draft":true}' \
  https://frem.sh/api/v1/repos/acme/web/releases
# 2. assets (repeat per file)
curl -sS -X POST -H "Authorization: token $TOKEN" -F attachment=@dist/web-linux-amd64.tar.gz \
  "https://frem.sh/api/v1/repos/acme/web/releases/$RELEASE_ID/assets?name=web-linux-amd64.tar.gz"
# 3. publish — this is the moment it freezes
curl -sS -X PATCH -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"draft":false}' https://frem.sh/api/v1/repos/acme/web/releases/$RELEASE_ID

Release-Werkzeuge, die ein veröffentlichtes Release anlegen und erst danach hochladen, scheitern beim Upload mit 422. Assets, die nur externe Links sind, können nicht Teil eines unveränderlichen Releases sein, weil es keine gespeicherten Bytes gibt, von denen ein Digest gebildet werden könnte; die Veröffentlichung eines solchen Releases wird abgewiesen.

Jedes Asset trägt seinen Digest jetzt in der API, wie bei GitHub: "digest": "sha256:<hex>", und ein unveränderliches Release meldet "immutable": true.

Container-Tags

Dieselbe Richtlinie kann Container-Image-Tags in der Registry einfrieren:

ModusEingefroren
off (Standard)nichts: Jeder Tag kann überschrieben werden
semverVersions-Tags: v1.2.3, 1.2.3, 1.2.3-rc.1; latest, 1, 1.2, main, sha-… bleiben verschiebbar
alljeder Tag außer den verschiebbaren, die du auflistest (latest, edge-*, …)

Ein Push eines eingefrorenen Tags, der ihn auf einen anderen Digest zeigen lässt, wird mit dem Registry-Fehler DENIED (HTTP 403) abgewiesen. Dasselbe Image erneut zu pushen, ist erlaubt, sodass ein erneut ausgeführter CI-Job nicht scheitert. Das Löschen eines eingefrorenen Tags wird abgewiesen, und Bereinigungsregeln für Pakete wählen nie einen aus; eingefrorene Tags zählen weiterhin zu deinem Speicherkontingent. Die Signatur- und Attestierungs-Tags, die cosign schreibt (sha256-<digest>.sig, .att, .sbom), sind immer verschiebbar. Container-Tags werden gegen die aktuelle Richtlinie geprüft; um einen Tag bewusst zu verschieben oder zu löschen, schaltest du den Modus also aus.

Pakete

Veröffentlichte Paketversionen sind bei fremforge unveränderlich: npm, Maven, PyPI, NuGet, Cargo, Go, Helm, Conan, Composer, RubyGems, Pub, Swift, Conda, CRAN, Debian, RPM, Alpine, Arch, Chef, Vagrant und generische Pakete. Das ist standardmäßig für jede Organisation eingeschaltet. Container folgen stattdessen dem oben beschriebenen Modus für Container-Tags.

ÄnderungErlaubt?
Eine neue Version hochladenJa
Einer bestehenden Version eine neue Datei hinzufügen (ein weiteres Wheel für eine Plattform, ein Maven-Classifier-JAR)Ja
Eine Datei mit denselben Bytes erneut hochladen (ein erneut ausgeführter CI-Job)Ja, ändert nichts
Eine Datei einer veröffentlichten Version durch andere Bytes ersetzenNein
Eine Version oder eine ihrer Dateien löschenJa
Eine gelöschte Version erneut veröffentlichen, mit beliebigen BytesNein: Der Versionsname bleibt stillgelegt
Eine gelöschte Datei mit anderen Bytes erneut hochladenNein

Ein abgewiesener Upload antwortet mit HTTP 409 und einer Meldung, die mit immutable package: beginnt, und der Response-Header X-Fremforge-Immutable nennt den Grund: overwrite, reupload oder retired. Jede Abweisung wird in deinem Audit-Log als package.immutable.refused erfasst, mit Paket, Version, Datei, beiden SHA-256-Digests und wer es versucht hat. Sie erscheint außerdem im Zugriffsprotokoll der Registry als blockierter Upload.

Die meisten Registrys weisen es ohnehin ab, eine Version zweimal zu veröffentlichen. Was sich hier ändert:

  • Helm und Conan erlaubten bisher, eine Version an Ort und Stelle zu überschreiben.
  • In jeder Registry ließ sich eine Version löschen und dann mit anderem Inhalt erneut veröffentlichen. Beides wird jetzt abgewiesen.

Immer veränderlich:

  • Maven-Versionen -SNAPSHOT und maven-metadata.xml-Dateien, die absichtlich neu geschrieben werden.
  • Die Repository-Indexdateien, die Debian-, RPM-, Alpine- und Arch-Registrys pflegen.

Um andere Versionen veränderlich zu halten, zum Beispiel ein generisches Paket, das als CI-Cache dient, trägst du sie als Ausnahmen in der Form [<type>:][<name>@]<version> ein, wobei * und ? als Platzhalter funktionieren:

EintragHält veränderlich
generic:ci-cache@*jede Version des generischen Pakets ci-cache
npm:@acme/ui@0.0.0-dev*Entwicklungs-Builds eines npm-Pakets
*-nightlydie *-nightly-Versionen jedes Pakets

Eine Organisation kann die Unveränderlichkeit von Paketen auch ganz ausschalten oder auf den Plattformstandard (inherit) zurücksetzen. Die Einstellung wird zum Zeitpunkt eines Schreibvorgangs ausgewertet, und die Namen von Versionen, die gelöscht wurden, während sie aus war, bleiben trotzdem gespeichert. Das Einschalten schützt also auch Versionen, die schon früher gelöscht wurden.

Einschalten

Owner einer Organisation: Sicherheit › Richtlinien › Unveränderliche Releases in der Konsole (/<org>/_admin/security/policies/immutable-releases) oder die API mit einem Token, das policy:write trägt (zum Lesen der Einstellungen genügt findings:read):

curl -sS -X PUT -H "Authorization: Bearer $FFP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"releases":true,"allow_repo_opt_out":false,"oci_mode":"semver","oci_mutable_tags":["latest"]}' \
  https://frem.sh/_app/api/v1/orgs/acme/code-security/immutability

Die Unveränderlichkeit von Paketen nutzt denselben Endpunkt. packages ist inherit (folgt dem Plattformstandard, der eingeschaltet ist), on oder off, und package_mutable_versions listet die Ausnahmen auf. Die Antwort meldet außerdem packages_effective:

curl -sS -X PUT -H "Authorization: Bearer $FFP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"packages":"inherit","package_mutable_versions":["generic:ci-cache@*"]}' \
  https://frem.sh/_app/api/v1/orgs/acme/code-security/immutability

Ein Repository kann die Organisation übersteuern: on friert Releases dort immer ein, auch wenn der Standard der Organisation aus ist; off steigt aus und wird nur beachtet, wenn die Organisation das Aussteigen erlaubt; inherit folgt der Organisation.

curl -sS -X PUT -H "Authorization: Bearer $FFP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"releases":"on"}' https://frem.sh/_app/api/v1/orgs/acme/code-security/immutability/repos/web

Pull-Mirrors werden nie eingefroren: Ihre Tags gehören dem Repository, das sie spiegeln.

Die Release-Attestierung

Für jedes unveränderliche Release signiert fremforge eine in-toto-Aussage vom Typ https://in-toto.io/attestation/release/v0.1. Ihre Subjekte sind:

  • jedes Asset, mit Namen und SHA-256 (berechnet während des Uploads der Datei);
  • die Quelle, git+https://frem.sh/<org>/<repo>@refs/tags/<tag>, per gitCommit.

Die Aussage stammt aus einem Manifest, das Forgejo in derselben Transaktion schreibt, die das Release veröffentlicht, und wird vor dem Signieren erneut gegen das live veröffentlichte Release geprüft. Sie wird mit denselben Schlüsseln signiert wie jede andere fremforge-Attestierung (eine Ed25519-DSSE-Signatur plus ein schlüsselloses Sigstore-Bundle von fremforges eigenem Fulcio und eigener Zeitstempelstelle) und in das Transparenzprotokoll eingetragen. Die Attestierung wird über die API und die CLI ausgeliefert; sie wird nie an das Release selbst angehängt.

Das Signieren verzögert nie eine Veröffentlichung. Ein Release wird zuerst veröffentlicht und kurz danach signiert, sodass seine Attestierung für kurze Zeit ausstehend ist. Ausstehend ist kein Bestehen: Die Verifikation meldet PENDING und endet mit einem eigenen Exit-Code.

Ein Release verifizieren

fremforge release verify acme/web v1.2.3 ./web-linux-amd64.tar.gz ./checksums.txt

Der Befehl holt die Attestierung, verifiziert die Signatur gegen den veröffentlichten Trust Root (https://www.frem.sh/.well-known/slsa-trust-root.json), prüft, dass die Aussage für dieses Repository und diesen Tag gilt, hasht jede Datei, die du angibst, auf deinem Rechner und vergleicht sie mit dem signierten Digest, und prüft, dass der Umschlag im Transparenzprotokoll steht. Eine Datei, die das Release nicht nennt, scheitert.

Exit-CodeBedeutung
0PASS
1FAIL (Signatur, Aussage, Digest einer Datei, das Log, oder die Attestierung wurde abgewiesen)
2Bedienfehler
3PENDING: veröffentlicht, noch nicht signiert; kurz darauf erneut ausführen

Offline, ganz ohne Netzwerk, mit dem Umschlag und einer Kopie des Trust Root:

fremforge release verify acme/web v1.2.3 ./web-linux-amd64.tar.gz \
  --offline --envelope=release.intoto.jsonl --trust-root=slsa-trust-root.json

Die Offline-Verifikation prüft die Signatur, die Aussage und deine Dateien; den Status der Attestierung und das Transparenzprotokoll kann sie nicht prüfen, und sie sagt das auch.

Die Rohdaten liegen unter GET /_app/api/v1/orgs/{org}/repos/{repo}/releases/{tag}/attestation (findings:read): status (pending, signed, failed, unfrozen), die Subjekte, das Manifest und, sobald signiert, kurzlebige URLs für den DSSE- Umschlag und das Sigstore-Bundle. Nur signed enthält einen Umschlag.

Nicht abgedeckt

  • Die automatisch erzeugten Quellarchive (die .zip- und .tar.gz-Dateien, die Forgejo für jeden Tag anbietet) werden nicht attestiert: Ihre Bytes sind zwischen Git-Versionen nicht stabil. Das Quell-Subjekt nennt stattdessen den Commit des Tags; verifiziere einen Checkout mit git rev-parse v1.2.3^{commit}.
  • Releases, die veröffentlicht wurden, bevor die Unveränderlichkeit eingeschaltet war, sind nicht eingefroren und haben keine Release-Attestierung.

Fehler

OberflächeForm
Forgejo-REST-API422 {"message": "immutable release: …"}
git pushremote: … pre-receive hook declined mit dem Grund
Container-Registry403 {"errors":[{"code":"DENIED", …}]}
fremforge-APInot-enabled, invalid-value, invalid-repo, forgejo-error, not-found