Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions .github/scripts/sbom-scan.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# This file is part of midnight-node.
# Copyright (C) 2026 Midnight Foundation
# SPDX-License-Identifier: Apache-2.0
# Licensed under the Apache License, Version 2.0 (the "License");
# You may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Generate SBOM with Syft, scan with Grype, and attest SBOM with Cosign.
#
# Usage:
# source .github/scripts/sbom-scan.sh
# generate_sbom_with_retry "ghcr.io/midnight-ntwrk/midnight-node:v1.0.0" "sbom.spdx.json"
# scan_image_with_retry "ghcr.io/midnight-ntwrk/midnight-node:v1.0.0" "high" "scan-results.json"
# attest_sbom_with_retry "ghcr.io/midnight-ntwrk/midnight-node:v1.0.0" "sbom.spdx.json"

# Note: We intentionally don't use `set -euo pipefail` at the top level because
# this script is designed to be sourced. Those settings would affect the caller's
# shell and cause it to exit on any error. Each function handles errors explicitly.

generate_sbom_with_retry() {
local IMAGE="$1"
local OUTPUT_FILE="$2"
local MAX_ATTEMPTS=3
local DELAY=10

command -v syft >/dev/null 2>&1 || { echo "::error::syft not found"; return 1; }

echo "Generating SBOM for ${IMAGE}"

for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do
if syft "${IMAGE}" -o spdx-json="${OUTPUT_FILE}"; then
echo "Successfully generated SBOM for ${IMAGE}"
return 0
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
echo "SBOM generation failed, retrying in ${DELAY}s..."
sleep $DELAY
DELAY=$((DELAY * 2))
fi
done

echo "::error::Failed to generate SBOM for ${IMAGE} after $MAX_ATTEMPTS attempts"
return 1
}

scan_image_with_retry() {
local IMAGE="$1"
local SEVERITY_CUTOFF="${2:-high}"
local OUTPUT_FILE="${3:-}"
local MAX_ATTEMPTS=3
local DELAY=10

command -v grype >/dev/null 2>&1 || { echo "::error::grype not found"; return 1; }

echo "Scanning ${IMAGE} for vulnerabilities (fail on ${SEVERITY_CUTOFF}+)"

# Build grype command - always show table output, optionally save JSON
local grype_cmd="grype ${IMAGE} --fail-on ${SEVERITY_CUTOFF}"
if [ -n "$OUTPUT_FILE" ]; then
# Show table on stdout AND write JSON to file
grype_cmd="${grype_cmd} --output table --output json=${OUTPUT_FILE}"
fi

for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do
local exit_code=0
eval "${grype_cmd}" || exit_code=$?

if [ $exit_code -eq 0 ]; then
echo "No vulnerabilities at or above ${SEVERITY_CUTOFF} severity found in ${IMAGE}"
return 0
elif [ $exit_code -eq 2 ]; then
# Exit code 2 means vulnerabilities were found above threshold - display summary before failing
if [ -n "$OUTPUT_FILE" ] && [ -f "$OUTPUT_FILE" ]; then
echo "::group::Vulnerability Summary"
jq -r '.matches[] | "\(.vulnerability.severity): \(.vulnerability.id) in \(.artifact.name)@\(.artifact.version)"' "$OUTPUT_FILE" 2>/dev/null | sort | uniq -c | sort -rn || true
echo "::endgroup::"
fi
echo "::error::Vulnerabilities at or above ${SEVERITY_CUTOFF} severity found in ${IMAGE}"
return 1
else
# Exit code 1 = general error, other codes = transient failures - retry
if [ $attempt -lt $MAX_ATTEMPTS ]; then
echo "Scan failed with exit code ${exit_code}, retrying in ${DELAY}s..."
sleep $DELAY
DELAY=$((DELAY * 2))
fi
fi
done

echo "::error::Failed to scan ${IMAGE} after $MAX_ATTEMPTS attempts"
return 1
}

attest_sbom_with_retry() {
local IMAGE="$1"
local SBOM_FILE="$2"
local MAX_ATTEMPTS=3
local DELAY=10

command -v cosign >/dev/null 2>&1 || { echo "::error::cosign not found"; return 1; }
command -v jq >/dev/null 2>&1 || { echo "::error::jq not found"; return 1; }

# Extract base image (without tag) for attestation
local BASE_IMAGE="${IMAGE%%:*}"

# Get the digest from the manifest
local DIGEST_JSON
if ! DIGEST_JSON=$(docker manifest inspect "${IMAGE}" --verbose 2>&1); then
echo "::error::Failed to inspect manifest for ${IMAGE}: ${DIGEST_JSON}"
return 1
fi

local DIGEST
if echo "$DIGEST_JSON" | jq -e 'type == "array"' > /dev/null 2>&1; then
DIGEST=$(echo "$DIGEST_JSON" | jq -r '.[0].Descriptor.digest')
else
DIGEST=$(echo "$DIGEST_JSON" | jq -r '.Descriptor.digest')
fi

if [ -z "$DIGEST" ] || [ "$DIGEST" = "null" ]; then
echo "::error::Failed to extract digest from manifest for ${IMAGE}"
echo "::error::Manifest JSON: ${DIGEST_JSON}"
return 1
fi

echo "Attesting SBOM for ${IMAGE} (${DIGEST})"

for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do
if cosign attest --yes \
--predicate "${SBOM_FILE}" \
--type spdxjson \
"${BASE_IMAGE}@${DIGEST}"; then
echo "Successfully attested SBOM for ${IMAGE}"

# Verify the attestation was applied correctly
echo "Verifying SBOM attestation..."
if cosign verify-attestation --type spdxjson \
--certificate-identity-regexp '.*' \
--certificate-oidc-issuer-regexp '.*' \
"${BASE_IMAGE}@${DIGEST}" > /dev/null 2>&1; then
echo "SBOM attestation verified successfully"
else
echo "::warning::SBOM attestation verification failed - attestation may not be retrievable"
fi

return 0
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
echo "SBOM attestation failed, retrying in ${DELAY}s..."
sleep $DELAY
DELAY=$((DELAY * 2))
fi
done

echo "::error::Failed to attest SBOM for ${IMAGE} after $MAX_ATTEMPTS attempts"
return 1
}
48 changes: 32 additions & 16 deletions .github/scripts/sign-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,43 @@ sign_with_retry() {
local DIGEST_JSON
DIGEST_JSON=$(docker manifest inspect "${IMAGE}" --verbose)

local DIGEST
# Collect all digests (single image = 1 digest, multi-arch manifest = multiple)
local DIGESTS
if echo "$DIGEST_JSON" | jq -e 'type == "array"' > /dev/null 2>&1; then
DIGEST=$(echo "$DIGEST_JSON" | jq -r '.[0].Descriptor.digest')
# Multi-arch manifest: get all platform digests
DIGESTS=$(echo "$DIGEST_JSON" | jq -r '.[].Descriptor.digest')
else
DIGEST=$(echo "$DIGEST_JSON" | jq -r '.Descriptor.digest')
# Single image
DIGESTS=$(echo "$DIGEST_JSON" | jq -r '.Descriptor.digest')
fi

echo "Signing ${IMAGE} (${DIGEST})"

for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do
if cosign sign --yes "${BASE_IMAGE}@${DIGEST}"; then
echo "Successfully signed ${IMAGE}"
return 0
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
echo "Signing failed, retrying in ${DELAY}s..."
sleep $DELAY
DELAY=$((DELAY * 2))
# Sign each digest
for DIGEST in $DIGESTS; do
echo "Signing ${IMAGE} (${DIGEST})"

local attempt
local signed=false
DELAY=10 # Reset delay for each digest

for ((attempt=1; attempt<=MAX_ATTEMPTS; attempt++)); do
if cosign sign --yes "${BASE_IMAGE}@${DIGEST}"; then
echo "Successfully signed ${IMAGE} (${DIGEST})"
signed=true
break
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
echo "Signing failed, retrying in ${DELAY}s..."
sleep $DELAY
DELAY=$((DELAY * 2))
fi
done

if [ "$signed" = false ]; then
echo "::error::Failed to sign ${IMAGE} (${DIGEST}) after $MAX_ATTEMPTS attempts"
return 1
fi
done

echo "::error::Failed to sign ${IMAGE} after $MAX_ATTEMPTS attempts"
return 1
echo "Successfully signed all digests for ${IMAGE}"
return 0
}
30 changes: 30 additions & 0 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
FORCE_COLOR: 1
outputs:
sha: ${{ steps.get_sha.outputs.sha }}
node_image_tag: ${{ steps.image_tags.outputs.node_image_tag }}
toolkit_image_tag: ${{ steps.image_tags.outputs.toolkit_image_tag }}
steps:
- uses: EarthBuild/actions-setup@cae2d9ab68894d8402751fe42e07c7cca0272f7f
with:
Expand Down Expand Up @@ -61,6 +63,14 @@ jobs:

. ./.envrc && earthly --secret GITHUB_TOKEN=${{ secrets.MIDNIGHTCI_PACKAGES_READ }} -P --ci --push +images

- name: Export image tags
id: image_tags
run: |
short_hash=$(git rev-parse --short=8 HEAD)
version=$(grep -m 1 '^version =' node/Cargo.toml | cut -d '"' -f2)
echo "node_image_tag=${version}-dev-${short_hash}-amd64" >> $GITHUB_OUTPUT
echo "toolkit_image_tag=${version}-${short_hash}-amd64" >> $GITHUB_OUTPUT

- name: Upload build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
Expand All @@ -75,6 +85,26 @@ jobs:
name: Test artifacts (amd64)
path: test-artifacts-amd64/

sbom-scan-node:
name: SBOM/Scan Node
needs: [run]
uses: ./.github/workflows/sbom-scan-image.yml
with:
image: ghcr.io/midnight-ntwrk/midnight-node:${{ needs.run.outputs.node_image_tag }}
sbom-artifact-name: sbom-midnight-node-ci
skip-attestation: ${{ github.event.pull_request.head.repo.fork || false }}
secrets: inherit

sbom-scan-toolkit:
name: SBOM/Scan Toolkit
needs: [run]
uses: ./.github/workflows/sbom-scan-image.yml
with:
image: ghcr.io/midnight-ntwrk/midnight-node-toolkit:${{ needs.run.outputs.toolkit_image_tag }}
sbom-artifact-name: sbom-midnight-node-toolkit-ci
skip-attestation: ${{ github.event.pull_request.head.repo.fork || false }}
secrets: inherit

run-arm64:
name: Build node and images (arm64)
# ARM64 gating: only for merge_group, workflow_dispatch, or PRs with ci:arm64 label
Expand Down
53 changes: 52 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,61 @@ jobs:

echo "✅ Images retagged and pushed successfully!"

sbom-scan-node-amd64:
name: SBOM/Scan Node (amd64)
needs: [publish-amd64]
uses: ./.github/workflows/sbom-scan-image.yml
with:
image: ghcr.io/midnight-ntwrk/midnight-node:${{ needs.publish-amd64.outputs.IMAGE_TAG }}-amd64
sbom-artifact-name: sbom-midnight-node-amd64
secrets: inherit

sbom-scan-node-arm64:
name: SBOM/Scan Node (arm64)
needs: [publish-amd64, publish-arm64]
uses: ./.github/workflows/sbom-scan-image.yml
with:
image: ghcr.io/midnight-ntwrk/midnight-node:${{ needs.publish-amd64.outputs.IMAGE_TAG }}-arm64
sbom-artifact-name: sbom-midnight-node-arm64
secrets: inherit

sbom-scan-toolkit-amd64:
name: SBOM/Scan Toolkit (amd64)
needs: [publish-amd64]
uses: ./.github/workflows/sbom-scan-image.yml
with:
image: ghcr.io/midnight-ntwrk/midnight-node-toolkit:${{ needs.publish-amd64.outputs.IMAGE_TAG }}-amd64
sbom-artifact-name: sbom-midnight-node-toolkit-amd64
secrets: inherit

sbom-scan-toolkit-arm64:
name: SBOM/Scan Toolkit (arm64)
needs: [publish-amd64, publish-arm64]
uses: ./.github/workflows/sbom-scan-image.yml
with:
image: ghcr.io/midnight-ntwrk/midnight-node-toolkit:${{ needs.publish-amd64.outputs.IMAGE_TAG }}-arm64
sbom-artifact-name: sbom-midnight-node-toolkit-arm64
secrets: inherit

sbom-scan-indexer-images:
name: SBOM/Scan Indexer (${{ matrix.image }})
needs: [build-indexer-images]
strategy:
matrix:
image:
- indexer-api
- chain-indexer
- wallet-indexer
uses: ./.github/workflows/sbom-scan-image.yml
with:
image: ghcr.io/midnight-ntwrk/${{ matrix.image }}:${{ needs.build-indexer-images.outputs.INDEXER_IMAGE_TAG }}
sbom-artifact-name: sbom-${{ matrix.image }}
secrets: inherit

publish-multi-arch:
name: Publish multi-arch image
runs-on: ubuntu-latest
needs: [publish-amd64, publish-arm64, build-indexer-images]
needs: [publish-amd64, publish-arm64, build-indexer-images, sbom-scan-node-amd64, sbom-scan-node-arm64, sbom-scan-toolkit-amd64, sbom-scan-toolkit-arm64, sbom-scan-indexer-images]
permissions:
contents: write
packages: write
Expand Down
Loading
Loading