Repository: runatlantis/atlantis (language: go)

Issue:
Sign container images
<!--- Please keep this note for the community --->

### Community Note

- Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
- Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
- If you are interested in working on this issue or have submitted a pull request, please leave a comment.

<!--- Thank you for keeping this note for the community --->

---

- [X] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md))

**Describe the user story**
Following on from PR #5158  , I'd like to continue the security hardening story by _signing_ container images, as well as attesting to their provenance. The solution implemented in 5158 attests to builds - that is, it verifies that an image was built by the entity claiming to have built it - but it doesn't actually sign the container images in a given build.

**Describe the solution you'd like**

The next logical step is signing images. This requires creation and persistence of a signing key in the Atlantis repo, which will then be used to request signing certificates from Sigstore. These will subsequently sign container images built during Atlantis releases. Downstream users can then use `cosign` to verify Atlantis images before use.

**Describe the drawbacks of your solution**

This solution introduces additional complexity into the already-complex Atlantis build process. In order to sign container images, a signing key must be generated and stored as a GH Actions Secret. By definition, the key must remain private; project contributors must not have access to it. Granting contributors access to the key compromises its confidentiality. Thus, the Atlantis maintainers must be involved in any PRs opened to complete this feature. 

Since Atlantis builds six different images, six signatures will be generated and published for each build. 

**Describe alternatives you've considered**

As in issue #5157, I explored forking Atlantis and signing images published by the fork. We found that this would lead to extra engineering burden on small teams. We believe it best for the canonical upstream repo to sign its images, so that any downstream forks can remain assured of the integrity of the images they use.



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/atlantis-image.yml b/.github/workflows/atlantis-image.yml
index 02f0f2dcec..0b8e8019df 100644
--- a/.github/workflows/atlantis-image.yml
+++ b/.github/workflows/atlantis-image.yml
@@ -53,6 +53,7 @@ jobs:
     strategy:
       matrix:
         image_type: [alpine, debian]
+        platform: [linux/arm64/v8, linux/amd64, linux/arm/v7]
     runs-on: ubuntu-24.04
     env:
       # Set docker repo to either the fork or the main repo where the branch exists
@@ -69,6 +70,11 @@ jobs:
       with:
         dockerfile: "Dockerfile"
 
+    - name: Set up Go
+      uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
+      with:
+        go-version-file: "go.mod"
+
     - name: Set up QEMU
       uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3
       with:
@@ -82,6 +88,10 @@ jobs:
         driver-opts: |
           image=moby/buildkit:v0.14.0
 
+    - name: "Install cosign"
+      uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
+      if: env.PUSH == 'true' && github.event_name != 'pull_request'
+
     # release version is the name of the tag i.e. v0.10.0
     # release version also has the image type appended i.e. v0.10.0-alpine
     # release tag is either pre-release or latest i.e. latest
@@ -146,21 +156,38 @@ jobs:
           ATLANTIS_VERSION=${{ env.RELEASE_VERSION }}
           ATLANTIS_COMMIT=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
           ATLANTIS_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
-        platforms: linux/arm64/v8,linux/amd64,linux/arm/v7
+        platforms: ${{ matrix.platform }}
         push: ${{ env.PUSH }}
         tags: ${{ steps.meta.outputs.tags }}
         target: ${{ matrix.image_type }}
         labels: ${{ steps.meta.outputs.labels }}
         outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.description'] }}
 
-    - name: "Sign and Attest Image"
-      if: env.PUSH == 'true'
+    - name: "Create Image Attestation"
+      if: env.PUSH == 'true' && github.event_name != 'pull_request'
       uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0
       with:
         subject-digest: ${{ steps.build.outputs.digest }}
         subject-name: ghcr.io/${{ github.repository }}
         push-to-registry: true
 
+    - name: "Sign images with environment annotations"
+      # no key needed, we're using the GitHub OIDC flow
+      # Only run on alpine/amd64 build to avoid signing multiple times
+      if: env.PUSH == 'true' && github.event_name != 'pull_request' && matrix.image_type == 'alpine' && matrix.platform == 'linux/amd64'
+      run: |
+        # Sign dev tags, version tags, and latest tags
+        echo "${TAGS}" | xargs -I {} cosign sign \
+          --yes \
+          --recursive=true \
+          -a actor=${{ github.actor}} \
+          -a ref_name=${{ github.ref_name}} \
+          -a ref=${{ github.sha }} \
+          {}@${DIGEST}
+      env:
+        TAGS: ${{ steps.meta.outputs.tags }}
+        DIGEST: ${{ steps.build.outputs.digest }}
+
   test:
     needs: [changes]
     if: needs.changes.outputs.should-run-build == 'true'
@@ -169,6 +196,7 @@ jobs:
     strategy:
       matrix:
         image_type: [alpine, debian]
+        platform: [linux/arm64/v8, linux/amd64, linux/arm/v7]
     env:
       # Set docker repo to either the fork or the main repo where the branch exists
       DOCKER_REPO: ghcr.io/${{ github.repository }}
@@ -215,4 +243,5 @@ jobs:
         image_type: [alpine, debian]
     runs-on: ubuntu-24.04
     steps:
-      - run: 'echo "No build required"'
\ No newline at end of file
+      - run: 'echo "No build required"'
+


Test patch: