mirror of
https://github.com/LearningCircuit/local-deep-research.git
synced 2026-06-15 19:46:56 +03:00
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:
122
.github/scripts/validate-docker-compose-images.sh
vendored
Executable file
122
.github/scripts/validate-docker-compose-images.sh
vendored
Executable 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
164
.github/scripts/validate-workflow-images.py
vendored
Executable 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())
|
||||
125
.github/workflows/validate-image-pinning.yml
vendored
Normal file
125
.github/workflows/validate-image-pinning.yml
vendored
Normal 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"
|
||||
Reference in New Issue
Block a user