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:
| Änderung | Erlaubt? |
|---|---|
| Titel, Release Notes, Pre-Release-Kennzeichen bearbeiten | Ja |
| Tag-Namen oder Ziel-Commit ändern | Nein |
Den Tag mit git push --force verschieben oder mit git push --delete löschen | Nein |
| Das Release wieder in einen Entwurf umwandeln | Nein |
| Ein Asset hinzufügen, umbenennen, ersetzen oder löschen | Nein |
| Das Release löschen | Ja, 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_IDRelease-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:
| Modus | Eingefroren |
|---|---|
off (Standard) | nichts: Jeder Tag kann überschrieben werden |
semver | Versions-Tags: v1.2.3, 1.2.3, 1.2.3-rc.1; latest, 1, 1.2, main, sha-… bleiben verschiebbar |
all | jeder 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.
| Änderung | Erlaubt? |
|---|---|
| Eine neue Version hochladen | Ja |
| 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 ersetzen | Nein |
| Eine Version oder eine ihrer Dateien löschen | Ja |
| Eine gelöschte Version erneut veröffentlichen, mit beliebigen Bytes | Nein: Der Versionsname bleibt stillgelegt |
| Eine gelöschte Datei mit anderen Bytes erneut hochladen | Nein |
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
-SNAPSHOTundmaven-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:
| Eintrag | Hä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 |
*-nightly | die *-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/immutabilityDie 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/immutabilityEin 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/webPull-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>, pergitCommit.
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.txtDer 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-Code | Bedeutung |
|---|---|
0 | PASS |
1 | FAIL (Signatur, Aussage, Digest einer Datei, das Log, oder die Attestierung wurde abgewiesen) |
2 | Bedienfehler |
3 | PENDING: 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.jsonDie 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 mitgit 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äche | Form |
|---|---|
| Forgejo-REST-API | 422 {"message": "immutable release: …"} |
| git push | remote: … pre-receive hook declined mit dem Grund |
| Container-Registry | 403 {"errors":[{"code":"DENIED", …}]} |
| fremforge-API | not-enabled, invalid-value, invalid-repo, forgejo-error, not-found |