Skip to content

Container Security Scanning #7

Container Security Scanning

Container Security Scanning #7

name: PR Container Security Scan
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'recipes/**'
- 'builder/**'
- 'Dockerfile*'
- '.github/workflows/pr-container-security-scan.yml'
env:
SCAN_OUTPUT_DIR: scan_results
TEMP_DIR: temp_scan
# Security scan configuration
ENABLE_GRYPE: true
ENABLE_TRIVY: true
ENABLE_SEMGREP: true
SEVERITY_THRESHOLD: medium # Options: low, medium, high, critical
MAX_SCAN_TIMEOUT: 1800 # 30 minutes
SEMGREP_TIMEOUT: 900 # 15 minutes
FAIL_ON_CRITICAL: false # Set to true to fail PR on critical vulnerabilities
jobs:
container-security-scan:
name: Automated Container Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
security-events: write
actions: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq curl wget git
docker --version
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then
pip install -r requirements.txt
fi
pip install flask jinja2 requests pyyaml jsonschema
- name: Install vulnerability scanners
run: |
# Install Grype
if [[ "${{ env.ENABLE_GRYPE }}" == "true" ]]; then
echo "Installing Grype..."
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
grype version
fi
# Install Trivy
if [[ "${{ env.ENABLE_TRIVY }}" == "true" ]]; then
echo "Installing Trivy..."
sudo apt-get install -y wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install -y trivy
trivy --version
fi
# Pull Semgrep Docker image
if [[ "${{ env.ENABLE_SEMGREP }}" == "true" ]]; then
echo "Pulling Semgrep Docker image..."
docker pull returntocorp/semgrep:latest
fi
- name: Create output directories
run: |
mkdir -p ${{ env.SCAN_OUTPUT_DIR }}
mkdir -p ${{ env.TEMP_DIR }}
- name: Detect changed containers
id: detect-containers
run: |
echo "Detecting changed container recipes..."
# Get changed files in this PR
git fetch origin ${{ github.base_ref }} --depth=1
changed_files=$(git diff --name-only origin/${{ github.base_ref }}..HEAD || git diff --name-only HEAD~1..HEAD)
echo "Changed files:"
echo "$changed_files"
# Extract recipe directories that changed
changed_recipes=$(echo "$changed_files" | grep '^recipes/' | cut -d'/' -f2 | sort -u)
echo "Changed recipes:"
echo "$changed_recipes"
# Store for next step
echo "$changed_recipes" > ${{ env.TEMP_DIR }}/changed_recipes.txt
# Count changed recipes
recipe_count=$(echo "$changed_recipes" | grep -v '^$' | wc -l)
echo "recipe_count=$recipe_count" >> $GITHUB_OUTPUT
if [ "$recipe_count" -eq 0 ]; then
echo "No container recipes changed in this PR"
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "Found $recipe_count changed recipe(s)"
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
- name: Build changed containers
if: steps.detect-containers.outputs.has_changes == 'true'
id: build-containers
run: |
echo "Building changed containers for scanning..."
built_images=""
while IFS= read -r recipe; do
if [ -z "$recipe" ]; then
continue
fi
echo "Processing recipe: $recipe"
# Check if recipe has build.yaml
if [ -f "recipes/$recipe/build.yaml" ]; then
echo "Building container for $recipe using builder/build.py..."
# Use the neurocontainers build system
if python builder/build.py generate "$recipe" --build; then
echo "Successfully built $recipe"
# Try to detect built image name (neurocontainers format)
image_name=$(docker images --format "{{.Repository}}:{{.Tag}}" | grep "$recipe" | head -1)
if [ -n "$image_name" ]; then
echo "Built image: $image_name"
built_images="${built_images}${image_name},"
fi
else
echo "Warning: Failed to build $recipe"
fi
else
echo "No build.yaml found for $recipe, skipping"
fi
done < ${{ env.TEMP_DIR }}/changed_recipes.txt
# Remove trailing comma
built_images="${built_images%,}"
echo "Built images: $built_images"
echo "built_images=$built_images" >> $GITHUB_OUTPUT
if [ -z "$built_images" ]; then
echo "No images were successfully built"
echo "has_built_images=false" >> $GITHUB_OUTPUT
else
echo "has_built_images=true" >> $GITHUB_OUTPUT
fi
- name: Scan containers with Grype
if: steps.build-containers.outputs.has_built_images == 'true' && env.ENABLE_GRYPE == 'true'
id: scan-grype
run: |
echo "Scanning built containers with Grype..."
IFS=',' read -ra IMAGES <<< "${{ steps.build-containers.outputs.built_images }}"
all_critical=0
all_high=0
all_medium=0
all_low=0
for image in "${IMAGES[@]}"; do
if [ -z "$image" ]; then
continue
fi
echo "Scanning image: $image"
# Sanitize image name for filename
safe_name=$(echo "$image" | tr '/:' '_')
# Get image info
docker inspect "$image" > "${{ env.SCAN_OUTPUT_DIR }}/image_info_${safe_name}.json" || true
# Run Grype scan with JSON output
grype "$image" \
--output json \
--file "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" || true
# Generate human-readable report
grype "$image" \
--output table \
--file "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.txt" || true
# Count vulnerabilities by severity
if [ -f "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" ]; then
critical=$(jq -r '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" 2>/dev/null || echo "0")
high=$(jq -r '[.matches[] | select(.vulnerability.severity == "High")] | length' "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" 2>/dev/null || echo "0")
medium=$(jq -r '[.matches[] | select(.vulnerability.severity == "Medium")] | length' "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" 2>/dev/null || echo "0")
low=$(jq -r '[.matches[] | select(.vulnerability.severity == "Low")] | length' "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" 2>/dev/null || echo "0")
echo "Image $image:"
echo " Critical: $critical"
echo " High: $high"
echo " Medium: $medium"
echo " Low: $low"
all_critical=$((all_critical + critical))
all_high=$((all_high + high))
all_medium=$((all_medium + medium))
all_low=$((all_low + low))
fi
done
echo "total_critical=$all_critical" >> $GITHUB_OUTPUT
echo "total_high=$all_high" >> $GITHUB_OUTPUT
echo "total_medium=$all_medium" >> $GITHUB_OUTPUT
echo "total_low=$all_low" >> $GITHUB_OUTPUT
- name: Scan containers with Trivy
if: steps.build-containers.outputs.has_built_images == 'true' && env.ENABLE_TRIVY == 'true'
id: scan-trivy
run: |
echo "Scanning built containers with Trivy..."
IFS=',' read -ra IMAGES <<< "${{ steps.build-containers.outputs.built_images }}"
trivy_critical=0
trivy_high=0
trivy_medium=0
trivy_low=0
for image in "${IMAGES[@]}"; do
if [ -z "$image" ]; then
continue
fi
echo "Scanning image: $image"
# Sanitize image name for filename
safe_name=$(echo "$image" | tr '/:' '_')
# Run Trivy scan with JSON output
trivy image \
--format json \
--output "${{ env.SCAN_OUTPUT_DIR }}/trivy_${safe_name}.json" \
--timeout 10m \
--severity "LOW,MEDIUM,HIGH,CRITICAL" \
"$image" || true
# Generate human-readable report
trivy image \
--format table \
--output "${{ env.SCAN_OUTPUT_DIR }}/trivy_${safe_name}.txt" \
--severity "LOW,MEDIUM,HIGH,CRITICAL" \
"$image" || true
# Count vulnerabilities by severity
if [ -f "${{ env.SCAN_OUTPUT_DIR }}/trivy_${safe_name}.json" ]; then
critical=$(jq -r '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' "${{ env.SCAN_OUTPUT_DIR }}/trivy_${safe_name}.json" 2>/dev/null || echo "0")
high=$(jq -r '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' "${{ env.SCAN_OUTPUT_DIR }}/trivy_${safe_name}.json" 2>/dev/null || echo "0")
medium=$(jq -r '[.Results[]?.Vulnerabilities[]? | select(.Severity == "MEDIUM")] | length' "${{ env.SCAN_OUTPUT_DIR }}/trivy_${safe_name}.json" 2>/dev/null || echo "0")
low=$(jq -r '[.Results[]?.Vulnerabilities[]? | select(.Severity == "LOW")] | length' "${{ env.SCAN_OUTPUT_DIR }}/trivy_${safe_name}.json" 2>/dev/null || echo "0")
echo "Image $image (Trivy):"
echo " Critical: $critical"
echo " High: $high"
echo " Medium: $medium"
echo " Low: $low"
trivy_critical=$((trivy_critical + critical))
trivy_high=$((trivy_high + high))
trivy_medium=$((trivy_medium + medium))
trivy_low=$((trivy_low + low))
fi
done
echo "trivy_critical=$trivy_critical" >> $GITHUB_OUTPUT
echo "trivy_high=$trivy_high" >> $GITHUB_OUTPUT
echo "trivy_medium=$trivy_medium" >> $GITHUB_OUTPUT
echo "trivy_low=$trivy_low" >> $GITHUB_OUTPUT
- name: Run Semgrep static analysis
if: steps.build-containers.outputs.has_built_images == 'true' && env.ENABLE_SEMGREP == 'true'
id: scan-semgrep
run: |
echo "Running Semgrep static analysis..."
IFS=',' read -ra IMAGES <<< "${{ steps.build-containers.outputs.built_images }}"
semgrep_total=0
semgrep_high=0
for image in "${IMAGES[@]}"; do
if [ -z "$image" ]; then
continue
fi
echo "Analyzing image: $image"
# Sanitize image name for filename
safe_name=$(echo "$image" | tr '/:' '_')
# Create container to extract filesystem
container_id=$(docker create "$image")
# Extract container filesystem
temp_extract_dir="${{ env.TEMP_DIR }}/container_fs_${safe_name}"
mkdir -p "$temp_extract_dir"
docker export "$container_id" | tar -xf - -C "$temp_extract_dir" 2>/dev/null || true
# Run Semgrep analysis
timeout 15m docker run --rm \
-v "$PWD/$temp_extract_dir":/src \
-v "$PWD/${{ env.SCAN_OUTPUT_DIR }}":/output \
returntocorp/semgrep:latest \
--config=auto \
--config=p/security-audit \
--config=p/secrets \
--json \
--output=/output/semgrep_${safe_name}.json \
/src 2>/dev/null || echo "Semgrep completed with warnings"
# Count issues
if [ -f "${{ env.SCAN_OUTPUT_DIR }}/semgrep_${safe_name}.json" ]; then
total=$(jq -r '.results | length' "${{ env.SCAN_OUTPUT_DIR }}/semgrep_${safe_name}.json" 2>/dev/null || echo "0")
high=$(jq -r '[.results[] | select(.extra.severity == "ERROR")] | length' "${{ env.SCAN_OUTPUT_DIR }}/semgrep_${safe_name}.json" 2>/dev/null || echo "0")
echo "Image $image (Semgrep):"
echo " Total issues: $total"
echo " High severity: $high"
semgrep_total=$((semgrep_total + total))
semgrep_high=$((semgrep_high + high))
fi
# Cleanup
docker rm "$container_id" || true
rm -rf "$temp_extract_dir"
done
echo "semgrep_total=$semgrep_total" >> $GITHUB_OUTPUT
echo "semgrep_high=$semgrep_high" >> $GITHUB_OUTPUT
- name: Generate comprehensive security reports
if: steps.build-containers.outputs.has_built_images == 'true'
run: |
echo "Generating comprehensive security reports..."
# Check if the report generator script exists
if [ ! -f "generate_comprehensive_security_report.py" ]; then
echo "Downloading comprehensive report generator..."
# Use the same script from the manual workflow
curl -sSL https://raw.githubusercontent.com/neurodesk/neurocontainers/main/generate_comprehensive_security_report.py -o generate_comprehensive_security_report.py 2>/dev/null || true
fi
# Generate comprehensive reports for each scanned image
IFS=',' read -ra IMAGES <<< "${{ steps.build-containers.outputs.built_images }}"
for image in "${IMAGES[@]}"; do
if [ -z "$image" ]; then
continue
fi
safe_name=$(echo "$image" | tr '/:' '_')
if [ -f "${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" ] && [ -f "generate_comprehensive_security_report.py" ]; then
echo "Generating comprehensive report for $image..."
python3 generate_comprehensive_security_report.py \
"${{ env.SCAN_OUTPUT_DIR }}/grype_${safe_name}.json" \
--image-info "${{ env.SCAN_OUTPUT_DIR }}/image_info_${safe_name}.json" \
--output "${{ env.SCAN_OUTPUT_DIR }}/comprehensive_${safe_name}.md" \
--quick || echo "Failed to generate comprehensive report for $image"
fi
done
- name: Generate PR summary report
if: steps.build-containers.outputs.has_built_images == 'true'
run: |
echo "Generating summary report for PR..."
cat > "${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md" << 'EOF'
# Container Security Scan Results
**PR:** #${{ github.event.pull_request.number }}
**Scan Date:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')
**Scanners:** Grype, Trivy, Semgrep
## Summary
This PR modifies container recipes. Comprehensive security scanning has been performed on all changed containers.
### Vulnerability Summary by Scanner
#### Grype Results
| Severity | Count |
|----------|-------|
| Critical | ${{ steps.scan-grype.outputs.total_critical || 0 }} |
| High | ${{ steps.scan-grype.outputs.total_high || 0 }} |
| Medium | ${{ steps.scan-grype.outputs.total_medium || 0 }} |
| Low | ${{ steps.scan-grype.outputs.total_low || 0 }} |
#### Trivy Results
| Severity | Count |
|----------|-------|
| Critical | ${{ steps.scan-trivy.outputs.trivy_critical || 0 }} |
| High | ${{ steps.scan-trivy.outputs.trivy_high || 0 }} |
| Medium | ${{ steps.scan-trivy.outputs.trivy_medium || 0 }} |
| Low | ${{ steps.scan-trivy.outputs.trivy_low || 0 }} |
#### Semgrep Static Analysis
| Metric | Count |
|--------|-------|
| Total Issues | ${{ steps.scan-semgrep.outputs.semgrep_total || 0 }} |
| High Severity | ${{ steps.scan-semgrep.outputs.semgrep_high || 0 }} |
### Scanned Images
EOF
IFS=',' read -ra IMAGES <<< "${{ steps.build-containers.outputs.built_images }}"
for image in "${IMAGES[@]}"; do
if [ -n "$image" ]; then
echo "- \`$image\`" >> "${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md"
fi
done
cat >> "${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md" << 'EOF'
### Risk Assessment
EOF
grype_critical=${{ steps.scan-grype.outputs.total_critical || 0 }}
trivy_critical=${{ steps.scan-trivy.outputs.trivy_critical || 0 }}
grype_high=${{ steps.scan-grype.outputs.total_high || 0 }}
trivy_high=${{ steps.scan-trivy.outputs.trivy_high || 0 }}
semgrep_high=${{ steps.scan-semgrep.outputs.semgrep_high || 0 }}
max_critical=$((grype_critical > trivy_critical ? grype_critical : trivy_critical))
max_high=$((grype_high > trivy_high ? grype_high : trivy_high))
if [ "$max_critical" -gt 0 ]; then
echo "**CRITICAL**: This PR introduces containers with **$max_critical critical vulnerabilities**. Please review and address before merging." >> "${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md"
elif [ "$max_high" -gt 10 ] || [ "$semgrep_high" -gt 5 ]; then
echo "**WARNING**: This PR introduces containers with multiple high severity issues. Review recommended." >> "${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md"
else
echo "**OK**: No critical vulnerabilities detected. Standard security practices apply." >> "${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md"
fi
cat >> "${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md" << 'EOF'
### Detailed Reports
Comprehensive security reports with EPSS scores and KEV data are available in the workflow artifacts:
- Individual scanner reports (JSON and text formats)
- Comprehensive security assessment reports
- Static analysis results
---
**Artifacts:** Download detailed scan results from the workflow artifacts section.
**Manual Scan:** For even more detailed analysis, use the [Manual Container Security Scan workflow](../../actions/workflows/manual-container-security-scan.yml).
**Security Tools Used:**
- **Grype** - Vulnerability scanner for container images
- **Trivy** - Comprehensive security scanner
- **Semgrep** - Static analysis for code security patterns
EOF
- name: Comment on PR
if: steps.build-containers.outputs.has_built_images == 'true'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const summary = fs.readFileSync('${{ env.SCAN_OUTPUT_DIR }}/pr_security_summary.md', 'utf8');
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Container Security Scan Results')
);
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: summary
});
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: summary
});
}
- name: Upload scan results
if: steps.build-containers.outputs.has_built_images == 'true'
uses: actions/upload-artifact@v4
with:
name: pr-security-scan-results-${{ github.event.pull_request.number }}
path: |
${{ env.SCAN_OUTPUT_DIR }}/
retention-days: 30
- name: Check for critical vulnerabilities
if: steps.build-containers.outputs.has_built_images == 'true'
run: |
grype_critical=${{ steps.scan-grype.outputs.total_critical || 0 }}
trivy_critical=${{ steps.scan-trivy.outputs.trivy_critical || 0 }}
semgrep_high=${{ steps.scan-semgrep.outputs.semgrep_high || 0 }}
max_critical=$((grype_critical > trivy_critical ? grype_critical : trivy_critical))
echo "=== Security Scan Summary ==="
echo "Grype Critical: $grype_critical"
echo "Trivy Critical: $trivy_critical"
echo "Semgrep High: $semgrep_high"
echo "Max Critical: $max_critical"
if [ "$max_critical" -gt 0 ]; then
echo ""
echo "[CRITICAL] Found $max_critical critical vulnerabilities!"
echo "Please review the scan results and address critical issues before merging."
echo ""
echo "TIP: Check the workflow artifacts for detailed reports."
# Fail workflow if configured to do so
if [[ "${{ env.FAIL_ON_CRITICAL }}" == "true" ]]; then
echo ""
echo "[FAIL] FAIL_ON_CRITICAL is enabled. Failing the workflow."
exit 1
fi
elif [ "$semgrep_high" -gt 5 ]; then
echo ""
echo "[WARNING] Found $semgrep_high high-severity static analysis issues."
echo "Review recommended before merging."
else
echo ""
echo "[OK] No critical vulnerabilities found"
echo "Standard security practices apply."
fi
- name: Cleanup
if: always()
run: |
echo "Cleaning up temporary files..."
rm -rf ${{ env.TEMP_DIR }}
docker system prune -f || true