|
| 1 | +# =============================================================== |
| 2 | +# 📦 Secure Docker Build & Scan Workflow |
| 3 | +# =============================================================== |
| 4 | +# |
| 5 | +# This workflow: |
| 6 | +# • Builds and tags the container image (`latest` + timestamp) |
| 7 | +# • Re-uses a BuildKit layer cache for faster rebuilds |
| 8 | +# • Lints the Dockerfile with **Hadolint** (CLI) → SARIF |
| 9 | +# • Lints the finished image with **Dockle** (CLI) → SARIF |
| 10 | +# • Generates an SPDX SBOM with **Syft** |
| 11 | +# • Scans the image for CRITICAL/HIGH CVEs with **Trivy** |
| 12 | +# • Uploads Hadolint, Dockle and Trivy results as SARIF files |
| 13 | +# • Pushes the image to **GitHub Container Registry (GHCR)** |
| 14 | +# • Signs & attests the image with **Cosign (key-less OIDC)** |
| 15 | +# |
| 16 | +# Triggers: |
| 17 | +# • Every push / PR to `main` |
| 18 | +# • Weekly scheduled run (Tue 18:17 UTC) to catch new CVEs |
| 19 | +# --------------------------------------------------------------- |
| 20 | + |
| 21 | +name: Secure Docker Build |
| 22 | + |
| 23 | +on: |
| 24 | + push: |
| 25 | + branches: [ "main" ] |
| 26 | + pull_request: |
| 27 | + branches: [ "main" ] |
| 28 | + schedule: |
| 29 | + - cron: '17 18 * * 2' # Tuesday @ 18:17 UTC |
| 30 | + |
| 31 | +# ----------------------------------------------------------------- |
| 32 | +# Minimal permissions – keep the principle of least privilege |
| 33 | +# ----------------------------------------------------------------- |
| 34 | +permissions: |
| 35 | + contents: read |
| 36 | + packages: write # push to ghcr.io via GITHUB_TOKEN |
| 37 | + security-events: write # upload SARIF to “Code scanning” |
| 38 | + actions: read # needed by upload-sarif in private repos |
| 39 | + id-token: write # required for OIDC token generation |
| 40 | + |
| 41 | +jobs: |
| 42 | + build-scan-sign: |
| 43 | + runs-on: ubuntu-latest |
| 44 | + |
| 45 | + env: |
| 46 | + CACHE_DIR: /tmp/.buildx-cache # BuildKit layer cache dir |
| 47 | + |
| 48 | + steps: |
| 49 | + # ------------------------------------------------------------- |
| 50 | + # 0️⃣ Checkout source |
| 51 | + # ------------------------------------------------------------- |
| 52 | + - name: ⬇️ Checkout code |
| 53 | + uses: actions/checkout@v4 |
| 54 | + |
| 55 | + # ------------------------------------------------------------- |
| 56 | + # 0️⃣.5️⃣ Derive lower-case IMAGE_NAME for Docker tag |
| 57 | + # ------------------------------------------------------------- |
| 58 | + - name: 🏷️ Set IMAGE_NAME (lower-case repo path) |
| 59 | + run: | |
| 60 | + IMAGE="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" |
| 61 | + echo "IMAGE_NAME=$IMAGE" >> "$GITHUB_ENV" |
| 62 | + echo "Will build & push: $IMAGE_NAME" |
| 63 | +
|
| 64 | + # ------------------------------------------------------------- |
| 65 | + # 1️⃣ Dockerfile lint (Hadolint CLI → SARIF) |
| 66 | + # ------------------------------------------------------------- |
| 67 | + - name: 🔍 Dockerfile lint (Hadolint) |
| 68 | + id: hadolint |
| 69 | + continue-on-error: true |
| 70 | + run: | |
| 71 | + curl -sSL https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64 -o /usr/local/bin/hadolint |
| 72 | + chmod +x /usr/local/bin/hadolint |
| 73 | + hadolint -f sarif Containerfile.lite > hadolint-results.sarif |
| 74 | + echo "HADOLINT_EXIT=$?" >> "$GITHUB_ENV" |
| 75 | + exit 0 |
| 76 | + - name: ☁️ Upload Hadolint SARIF |
| 77 | + if: always() |
| 78 | + uses: github/codeql-action/upload-sarif@v3 |
| 79 | + with: |
| 80 | + sarif_file: hadolint-results.sarif |
| 81 | + |
| 82 | + # ------------------------------------------------------------- |
| 83 | + # 2️⃣ Set up Buildx & restore cache |
| 84 | + # ------------------------------------------------------------- |
| 85 | + - name: 🛠️ Set up Docker Buildx |
| 86 | + uses: docker/setup-buildx-action@v3 |
| 87 | + |
| 88 | + - name: 🔄 Restore BuildKit layer cache |
| 89 | + uses: actions/cache@v4 |
| 90 | + with: |
| 91 | + path: ${{ env.CACHE_DIR }} |
| 92 | + key: ${{ runner.os }}-buildx-${{ github.sha }} |
| 93 | + restore-keys: ${{ runner.os }}-buildx- |
| 94 | + |
| 95 | + # ------------------------------------------------------------- |
| 96 | + # 3️⃣ Build & tag image (timestamp + latest) |
| 97 | + # ------------------------------------------------------------- |
| 98 | + - name: 🏗️ Build Docker image |
| 99 | + run: | |
| 100 | + TAG=$(date +%s) |
| 101 | + echo "TAG=$TAG" >> "$GITHUB_ENV" |
| 102 | + docker buildx build \ |
| 103 | + --file Containerfile.lite \ |
| 104 | + --tag $IMAGE_NAME:$TAG \ |
| 105 | + --tag $IMAGE_NAME:latest \ |
| 106 | + --cache-from type=local,src=${{ env.CACHE_DIR }} \ |
| 107 | + --cache-to type=local,dest=${{ env.CACHE_DIR }},mode=max \ |
| 108 | + --load \ |
| 109 | + . # build context is mandatory |
| 110 | +
|
| 111 | + # ------------------------------------------------------------- |
| 112 | + # 4️⃣ Image lint (Dockle CLI → SARIF) |
| 113 | + # ------------------------------------------------------------- |
| 114 | + - name: 🔍 Image lint (Dockle) |
| 115 | + id: dockle |
| 116 | + continue-on-error: true |
| 117 | + env: |
| 118 | + DOCKLE_VERSION: 0.4.15 |
| 119 | + run: | |
| 120 | + curl -sSL "https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.tar.gz" \ |
| 121 | + | tar -xz -C /usr/local/bin dockle |
| 122 | + dockle --exit-code 1 --format sarif \ |
| 123 | + --output dockle-results.sarif \ |
| 124 | + $IMAGE_NAME:latest |
| 125 | + echo "DOCKLE_EXIT=$?" >> "$GITHUB_ENV" |
| 126 | + exit 0 |
| 127 | + - name: ☁️ Upload Dockle SARIF |
| 128 | + if: always() |
| 129 | + uses: github/codeql-action/upload-sarif@v3 |
| 130 | + with: |
| 131 | + sarif_file: dockle-results.sarif |
| 132 | + |
| 133 | + # ------------------------------------------------------------- |
| 134 | + # 5️⃣ Generate SPDX SBOM with Syft |
| 135 | + # ------------------------------------------------------------- |
| 136 | + - name: 📄 Generate SBOM (Syft) |
| 137 | + uses: anchore/sbom-action@v0 |
| 138 | + with: |
| 139 | + image: ${{ env.IMAGE_NAME }}:latest |
| 140 | + output-file: sbom.spdx.json |
| 141 | + |
| 142 | + # ------------------------------------------------------------- |
| 143 | + # 6️⃣ Trivy CVE scan → SARIF |
| 144 | + # ------------------------------------------------------------- |
| 145 | + - name: 🛡️ Trivy vulnerability scan |
| 146 | + id: trivy |
| 147 | + continue-on-error: true |
| 148 | + uses: aquasecurity/trivy-action@7b7aa264d83dc58691451798b4d117d53d21edfe |
| 149 | + with: |
| 150 | + image-ref: ${{ env.IMAGE_NAME }}:latest |
| 151 | + format: sarif |
| 152 | + output: trivy-results.sarif |
| 153 | + severity: CRITICAL,HIGH |
| 154 | + exit-code: 1 |
| 155 | + - name: ☁️ Upload Trivy SARIF |
| 156 | + if: always() |
| 157 | + uses: github/codeql-action/upload-sarif@v3 |
| 158 | + with: |
| 159 | + sarif_file: trivy-results.sarif |
| 160 | + |
| 161 | + # ------------------------------------------------------------- |
| 162 | + # 7️⃣ Push both tags to GHCR |
| 163 | + # ------------------------------------------------------------- |
| 164 | + - name: 🔑 Log in to GHCR |
| 165 | + uses: docker/login-action@v3 |
| 166 | + with: |
| 167 | + registry: ghcr.io |
| 168 | + username: ${{ github.actor }} |
| 169 | + password: ${{ secrets.GITHUB_TOKEN }} |
| 170 | + |
| 171 | + - name: 🚀 Push image to GHCR |
| 172 | + run: | |
| 173 | + docker push $IMAGE_NAME:${{ env.TAG }} |
| 174 | + docker push $IMAGE_NAME:latest |
| 175 | +
|
| 176 | + # ------------------------------------------------------------- |
| 177 | + # 8️⃣ Key-less Cosign sign + attest (latest **and** timestamp) |
| 178 | + # ------------------------------------------------------------- |
| 179 | + - name: 📥 Install Cosign |
| 180 | + uses: sigstore/cosign-installer@v3 # provides the matching CLI |
| 181 | + |
| 182 | + - name: 🔏 Sign & attest images (latest + timestamp) |
| 183 | + env: |
| 184 | + COSIGN_EXPERIMENTAL: "1" |
| 185 | + run: | |
| 186 | + for REF in $IMAGE_NAME:latest $IMAGE_NAME:${{ env.TAG }}; do |
| 187 | + echo "🔑 Signing $REF" |
| 188 | + cosign sign --yes "$REF" |
| 189 | + |
| 190 | + echo "📝 Attesting SBOM for $REF" |
| 191 | + cosign attest --yes \ |
| 192 | + --predicate sbom.spdx.json \ |
| 193 | + --type spdxjson \ |
| 194 | + "$REF" |
| 195 | + done |
| 196 | +
|
| 197 | + # ------------------------------------------------------------- |
| 198 | + # 9️⃣ Single gate – fail job on any scanner error |
| 199 | + # ------------------------------------------------------------- |
| 200 | + - name: ⛔ Enforce lint & vuln gates |
| 201 | + if: | |
| 202 | + env.HADOLINT_EXIT != '0' || |
| 203 | + env.DOCKLE_EXIT != '0' || |
| 204 | + steps.trivy.outcome == 'failure' |
| 205 | + run: | |
| 206 | + echo "Hadolint exit: $HADOLINT_EXIT" |
| 207 | + echo "Dockle exit: $DOCKLE_EXIT" |
| 208 | + echo "Trivy status: ${{ steps.trivy.outcome }}" |
| 209 | + exit 1 |
0 commit comments