feat: add CI validation for Docker image SHA digest pinning

Add comprehensive validation to enforce SHA256 digest pinning across all
Docker image references (Dockerfiles, docker-compose, and workflow files).

New Files:
- .github/scripts/validate-docker-compose-images.sh
  Bash script that validates docker-compose.yml files for unpinned images.
  Allows documented exceptions for own images and templates.

- .github/scripts/validate-workflow-images.py
  Python script with proper YAML parsing to validate GitHub Actions
  workflow service containers and container images.

- .github/workflows/validate-image-pinning.yml
  CI workflow that runs both validators on PR changes. Provides clear
  error messages and fix instructions when violations are found.

Why This Matters:
Image tags are mutable and can be reassigned to malicious images in supply
chain attacks. SHA256 digests are immutable cryptographic identifiers that
guarantee the exact same image bytes every deployment.

This validation:
- Blocks PRs with unpinned images
- Shows violations directly in PR checks (not just Security tab)
- Provides clear fix instructions
- Runs efficiently (only on relevant file changes)

Complements:
- PR #1184 (pins Dockerfile and workflow images)
- PR #1218 (pins docker-compose images)
This commit is contained in:
LearningCircuit
2025-12-03 12:35:09 +01:00
parent 2bca5e2b3b
commit ea73116db6
3 changed files with 411 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
#!/bin/bash
# Validates that all docker-compose image references use SHA256 digests
# Prevents supply chain attacks by ensuring immutable image references
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Configuration - images that are allowed without SHA digests
ALLOWED_EXCEPTIONS=(
"localdeepresearch/local-deep-research:latest" # Own image, built by CI
)
# Check if an image reference is in the exceptions list
is_exception() {
local image="$1"
for exception in "${ALLOWED_EXCEPTIONS[@]}"; do
if [[ "$image" == "$exception" ]]; then
return 0
fi
done
return 1
}
# Validate a single docker-compose file
validate_compose_file() {
local file="$1"
local violations=0
local line_num=0
while IFS= read -r line; do
line_num=$((line_num + 1))
# Check if this line contains an image reference
if [[ "$line" =~ ^[[:space:]]*image:[[:space:]]*(.+)$ ]]; then
local image="${BASH_REMATCH[1]}"
image=$(echo "$image" | tr -d '"' | xargs) # Remove quotes and whitespace
# Skip if it's an exception
if is_exception "$image"; then
echo -e "${YELLOW} Line $line_num: $image (exception)${NC}"
continue
fi
# Check if image has SHA digest
if [[ ! "$image" =~ @sha256: ]]; then
echo -e "${RED} ❌ Line $line_num: Missing SHA digest${NC}"
echo -e "${RED} Image: $image${NC}"
violations=$((violations + 1))
else
echo -e "${GREEN} ✓ Line $line_num: $image${NC}"
fi
fi
done < "$file"
return $violations
}
# Main validation logic
main() {
local total_violations=0
local files_checked=0
echo "🔍 Validating docker-compose image pinning..."
echo ""
# Find all docker-compose files
while IFS= read -r compose_file; do
# Skip cookiecutter templates and examples (documentation only)
if [[ "$compose_file" =~ cookiecutter-docker/ ]] || [[ "$compose_file" =~ examples/ ]]; then
echo -e "${YELLOW}⏭ Skipping: $compose_file (template/example)${NC}"
continue
fi
echo "📄 Checking: $compose_file"
if validate_compose_file "$compose_file"; then
: # No violations
else
violations=$?
total_violations=$((total_violations + violations))
fi
files_checked=$((files_checked + 1))
echo ""
done < <(find . -name "docker-compose*.yml" -o -name "docker-compose*.yaml")
# Summary
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📊 Summary:"
echo " Files checked: $files_checked"
echo " Violations: $total_violations"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $total_violations -gt 0 ]; then
echo ""
echo -e "${RED}❌ Found $total_violations unpinned images in docker-compose files${NC}"
echo ""
echo "Images must use SHA256 digests for security and reproducibility."
echo ""
echo "To fix:"
echo " 1. Pull the image: docker pull <image:tag>"
echo " 2. Get digest: docker inspect <image:tag> | jq -r '.[0].RepoDigests[0]'"
echo " 3. Update file: image: <image:tag>@sha256:..."
echo ""
echo "Example:"
echo " # Bad"
echo " image: ollama/ollama:latest"
echo ""
echo " # Good"
echo " image: ollama/ollama:latest@sha256:8850b8b33936b9fb..."
exit 1
fi
echo ""
echo -e "${GREEN}✅ All docker-compose images properly pinned${NC}"
exit 0
}
main "$@"

164
.github/scripts/validate-workflow-images.py vendored Executable file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Validates that all GitHub Actions workflow service containers and container images
use SHA256 digests for supply chain security.
Prevents tag tampering attacks by ensuring immutable image references.
"""
import sys
from pathlib import Path
from typing import List, Tuple
try:
import yaml
except ImportError:
print("❌ Error: PyYAML is required. Install with: pip install pyyaml")
sys.exit(1)
# ANSI color codes
class Colors:
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
NC = "\033[0m" # No Color
def has_sha_digest(image_ref: str) -> bool:
"""Check if image reference includes SHA256 digest."""
return "@sha256:" in image_ref
def validate_workflow(workflow_path: Path) -> List[Tuple[str, str, str]]:
"""
Validate a workflow file for unpinned images.
Returns:
List of (job_name, violation_type, image) tuples
"""
violations = []
try:
with open(workflow_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "jobs" not in data:
return violations
# Check each job
for job_name, job_def in data["jobs"].items():
if not isinstance(job_def, dict):
continue
# Check container: field
if "container" in job_def:
container = job_def["container"]
# Container can be a string or dict with 'image' key
if isinstance(container, str):
if not has_sha_digest(container):
violations.append((job_name, "container", container))
elif isinstance(container, dict) and "image" in container:
image = container["image"]
if not has_sha_digest(image):
violations.append((job_name, "container", image))
# Check services: field
if "services" in job_def and isinstance(job_def["services"], dict):
for service_name, service_def in job_def["services"].items():
if isinstance(service_def, dict) and "image" in service_def:
image = service_def["image"]
if not has_sha_digest(image):
violations.append(
(job_name, f"service '{service_name}'", image)
)
except yaml.YAMLError as e:
print(f"{Colors.RED}❌ YAML parse error in {workflow_path}:{Colors.NC}")
print(f" {e}")
# Return a violation to fail the check
violations.append(("parse_error", "error", str(e)))
except Exception as e:
print(
f"{Colors.RED}❌ Error processing {workflow_path}: {e}{Colors.NC}"
)
violations.append(("error", "error", str(e)))
return violations
def main():
"""Main validation logic."""
workflows_dir = Path(".github/workflows")
if not workflows_dir.exists():
print(
f"{Colors.RED}❌ .github/workflows directory not found{Colors.NC}"
)
sys.exit(1)
print("🔍 Validating GitHub Actions workflow images...")
print()
total_violations = 0
files_checked = 0
# Process all workflow files
for workflow_file in sorted(workflows_dir.glob("*.yml")) + sorted(
workflows_dir.glob("*.yaml")
):
violations = validate_workflow(workflow_file)
if violations:
print(f"{Colors.RED}📄 {workflow_file.name}:{Colors.NC}")
for job_name, violation_type, image in violations:
print(
f"{Colors.RED} ❌ Job '{job_name}' {violation_type}: {image}{Colors.NC}"
)
print()
total_violations += len(violations)
else:
print(f"{Colors.GREEN}{workflow_file.name}{Colors.NC}")
files_checked += 1
# Summary
print("" * 50)
print("📊 Summary:")
print(f" Files checked: {files_checked}")
print(f" Violations: {total_violations}")
print("" * 50)
if total_violations > 0:
print()
print(
f"{Colors.RED}❌ Found {total_violations} unpinned images in workflow files{Colors.NC}"
)
print()
print("Service container images must use SHA256 digests for security.")
print()
print("To fix:")
print(" 1. Pull the image: docker pull <image:tag>")
print(
" 2. Get digest: docker inspect <image:tag> | jq -r '.[0].RepoDigests[0]'"
)
print(" 3. Update workflow:")
print()
print("Example:")
print(" services:")
print(" redis:")
print(f"{Colors.RED} image: redis:alpine # Bad{Colors.NC}")
print(
f"{Colors.GREEN} image: redis:alpine@sha256:... # Good{Colors.NC}"
)
return 1
print()
print(f"{Colors.GREEN}✅ All workflow images properly pinned{Colors.NC}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,125 @@
name: Validate Docker Image Pinning
on:
pull_request:
branches: [main, dev]
paths:
- '**/Dockerfile*'
- '**/docker-compose*.yml'
- '**/docker-compose*.yaml'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '.github/workflows/validate-image-pinning.yml'
- '.github/scripts/validate-docker-compose-images.sh'
- '.github/scripts/validate-workflow-images.py'
push:
branches: [main, dev]
paths:
- '**/Dockerfile*'
- '**/docker-compose*.yml'
- '.github/workflows/*.yml'
workflow_dispatch:
permissions: {} # Minimal permissions for OSSF Scorecard
jobs:
validate-docker-compose:
name: Validate docker-compose Images
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Validate docker-compose image pinning
run: |
chmod +x .github/scripts/validate-docker-compose-images.sh
.github/scripts/validate-docker-compose-images.sh
validate-workflow-images:
name: Validate Workflow Service Containers
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: '3.11'
- name: Install PyYAML
run: pip install pyyaml
- name: Validate workflow image pinning
run: |
chmod +x .github/scripts/validate-workflow-images.py
python .github/scripts/validate-workflow-images.py
summary:
name: Image Pinning Validation Summary
needs: [validate-docker-compose, validate-workflow-images]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
steps:
- name: Generate summary
run: |
{
echo "## 🔒 Docker Image Pinning Validation"
echo ""
echo "### What is Image Pinning?"
echo ""
echo "Pinning Docker images with SHA256 digests ensures:"
echo ""
echo "- 🔒 **Security**: Protection against supply chain attacks"
echo "- 🔄 **Reproducibility**: Exact same image bytes every time"
echo "- 🛡️ **Immutability**: Tags like \`:latest\` can be changed, but SHA digests cannot"
echo ""
echo "### Validation Results"
echo ""
if [ "${{ needs.validate-docker-compose.result }}" = "success" ] && \
[ "${{ needs.validate-workflow-images.result }}" = "success" ]; then
echo "✅ **All images properly pinned with SHA256 digests**"
echo ""
echo "All docker-compose and workflow files pass validation."
else
echo "❌ **Image pinning violations found**"
echo ""
if [ "${{ needs.validate-docker-compose.result }}" != "success" ]; then
echo "- ❌ docker-compose files have unpinned images"
fi
if [ "${{ needs.validate-workflow-images.result }}" != "success" ]; then
echo "- ❌ Workflow files have unpinned service containers"
fi
echo ""
echo "See job logs above for details and fix instructions."
fi
echo ""
echo "### How to Fix Unpinned Images"
echo ""
echo "\`\`\`bash"
echo "# 1. Pull the image"
echo "docker pull <image:tag>"
echo ""
echo "# 2. Get the SHA digest"
echo "docker inspect <image:tag> | jq -r '.[0].RepoDigests[0]'"
echo ""
echo "# 3. Update your file"
echo "image: <image:tag>@sha256:..."
echo "\`\`\`"
echo ""
echo "### Resources"
echo ""
echo "- [OSSF Scorecard - Pinned Dependencies](https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies)"
echo "- [Docker Image Digests Documentation](https://docs.docker.com/engine/reference/commandline/pull/#pull-an-image-by-digest-immutable-identifier)"
} >> "$GITHUB_STEP_SUMMARY"