Skip to content

Docker Security Scan #331

Docker Security Scan

Docker Security Scan #331

name: Docker Security Scan
on:
schedule:
# Run daily at 2 AM UTC
- cron: "0 2 * * *"
workflow_dispatch: # Allow manual triggers
permissions:
contents: read
issues: write
packages: write
security-events: write
id-token: write
jobs:
scan:
name: Scan Docker Image for Vulnerabilities
runs-on: ubuntu-latest
outputs:
vulnerabilities_found: ${{ steps.check_vulns.outputs.vulnerabilities_found }}
current_tag: ${{ steps.get_tags.outputs.latest_version }}
old_vuln_count: ${{ steps.check_vulns.outputs.vuln_count }}
repo_lower: ${{ steps.repo_vars.outputs.repo_lower }}
steps:
- name: Set repository variables
id: repo_vars
run: |
REPO=${{ github.repository }}
echo "repo_lower=${REPO@L}" >> $GITHUB_OUTPUT
- name: Get latest release tag
id: get_tags
run: |
# Get the latest release version
LATEST_VERSION=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName --jq '.[0].tagName')
echo "latest_version=${LATEST_VERSION}" >> $GITHUB_OUTPUT
echo "Latest version: ${LATEST_VERSION}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Pull current Docker image
run: |
docker pull ghcr.io/${{ steps.repo_vars.outputs.repo_lower }}:latest
- name: Run Trivy vulnerability scanner on current image
id: scan
uses: aquasecurity/trivy-action@master
with:
image-ref: "ghcr.io/${{ steps.repo_vars.outputs.repo_lower }}:latest"
format: "json"
output: "trivy-results-old.json"
severity: "CRITICAL,HIGH"
exit-code: "0" # Don't fail the workflow
- name: Generate SARIF report for Security tab
run: |
docker run --rm \
-v $(pwd):/workspace \
aquasec/trivy:latest image \
--format sarif \
--output /workspace/trivy-results.sarif \
--severity CRITICAL,HIGH \
ghcr.io/${{ steps.repo_vars.outputs.repo_lower }}:latest
- name: Check for vulnerabilities
id: check_vulns
run: |
# Parse JSON to get detailed vulnerability info
VULN_COUNT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH")] | length' trivy-results-old.json)
echo "vuln_count=${VULN_COUNT}" >> $GITHUB_OUTPUT
echo "Found ${VULN_COUNT} CRITICAL/HIGH vulnerabilities"
# Save vulnerability details for comparison later
jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH") | {VulnerabilityID, PkgName, InstalledVersion, FixedVersion, Severity}]' trivy-results-old.json > vulnerabilities-old.json
if [ "$VULN_COUNT" -gt 0 ]; then
echo "vulnerabilities_found=true" >> $GITHUB_OUTPUT
echo "::warning::Found ${VULN_COUNT} vulnerabilities in current image"
# Display summary
echo "### Vulnerability Summary:"
jq -r '.[] | "- [\(.Severity)] \(.VulnerabilityID) in \(.PkgName) \(.InstalledVersion) (fix: \(.FixedVersion // "no fix available"))"' vulnerabilities-old.json | head -20
else
echo "vulnerabilities_found=false" >> $GITHUB_OUTPUT
echo "✓ No critical or high vulnerabilities found"
fi
- name: Upload vulnerability data as artifact
if: steps.check_vulns.outputs.vulnerabilities_found == 'true'
uses: actions/upload-artifact@v7
with:
name: vulnerability-data
path: |
trivy-results-old.json
vulnerabilities-old.json
retention-days: 7
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: "trivy-results.sarif"
- name: Create issue if vulnerabilities found
if: steps.check_vulns.outputs.vulnerabilities_found == 'true'
uses: actions/github-script@v9
with:
script: |
const issueTitle = '🔒 Security vulnerabilities detected in Docker base image';
const issueBody = `
## Security Scan Alert
The scheduled security scan has detected vulnerabilities in the Docker base image.
**Scan Date**: ${new Date().toISOString()}
**Image**: ghcr.io/${{ steps.repo_vars.outputs.repo_lower }}:latest
**Latest Version**: ${{ steps.get_tags.outputs.latest_version }}
### Actions Taken
- Vulnerability scan results have been uploaded to the Security tab
- A workflow to rebuild the Docker image will be triggered automatically
### Details
Check the [Security tab](https://github.com/${{ steps.repo_vars.outputs.repo_lower }}/security/code-scanning) for detailed vulnerability information.
---
*This issue was automatically created by the Docker Security Scan workflow*
`;
// Check if an issue already exists
const issues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: ['security', 'docker', 'automated']
});
const existingIssue = issues.data.find(issue => issue.title === issueTitle);
if (existingIssue) {
// Update existing issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: `## Updated Scan Results\n\n${issueBody}`
});
console.log(`Updated existing issue #${existingIssue.number}`);
} else {
// Create new issue
const issue = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: issueTitle,
body: issueBody,
labels: ['security', 'docker', 'automated']
});
console.log(`Created new issue #${issue.data.number}`);
}
rebuild:
name: Rebuild and Verify Docker Image
needs: scan
if: needs.scan.outputs.vulnerabilities_found == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.scan.outputs.current_tag }}
- name: Download vulnerability data
uses: actions/download-artifact@v8
with:
name: vulnerability-data
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.2
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from tag
id: version
run: |
TAG="${{ needs.scan.outputs.current_tag }}"
VERSION="${TAG#v}" # Remove 'v' prefix
echo "version=${VERSION}" >> $GITHUB_OUTPUT
# Extract major and major.minor versions
MAJOR=$(echo $VERSION | cut -d. -f1)
MINOR=$(echo $VERSION | cut -d. -f2)
echo "major=${MAJOR}" >> $GITHUB_OUTPUT
echo "major_minor=${MAJOR}.${MINOR}" >> $GITHUB_OUTPUT
- name: Build test image with fresh base
id: build-test
uses: docker/build-push-action@v7
with:
context: .
push: false
load: true
tags: ghcr.io/${{ needs.scan.outputs.repo_lower }}:test-rebuild
cache-from: type=gha
platforms: linux/amd64
# Force pull fresh base images
pull: true
no-cache: true
- name: Scan rebuilt image for vulnerabilities
id: scan_new
run: |
echo "Scanning newly built image..."
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $(pwd):/workspace \
aquasec/trivy:latest image \
--format json \
--output /workspace/trivy-results-new.json \
--severity CRITICAL,HIGH \
ghcr.io/${{ needs.scan.outputs.repo_lower }}:test-rebuild
# Extract new vulnerabilities
jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL" or .Severity == "HIGH") | {VulnerabilityID, PkgName, InstalledVersion, FixedVersion, Severity}]' trivy-results-new.json > vulnerabilities-new.json
NEW_VULN_COUNT=$(jq 'length' vulnerabilities-new.json)
OLD_VULN_COUNT=${{ needs.scan.outputs.old_vuln_count }}
echo "old_vuln_count=${OLD_VULN_COUNT}" >> $GITHUB_OUTPUT
echo "new_vuln_count=${NEW_VULN_COUNT}" >> $GITHUB_OUTPUT
echo "### Vulnerability Comparison:"
echo "Old image: ${OLD_VULN_COUNT} vulnerabilities"
echo "New image: ${NEW_VULN_COUNT} vulnerabilities"
if [ "$NEW_VULN_COUNT" -lt "$OLD_VULN_COUNT" ]; then
FIXED_COUNT=$((OLD_VULN_COUNT - NEW_VULN_COUNT))
echo "✓ Fixed ${FIXED_COUNT} vulnerabilities!"
echo "should_publish=true" >> $GITHUB_OUTPUT
elif [ "$NEW_VULN_COUNT" -eq "$OLD_VULN_COUNT" ]; then
echo "⚠ No vulnerabilities were fixed"
echo "should_publish=false" >> $GITHUB_OUTPUT
# Check if the vulnerabilities are exactly the same
OLD_IDS=$(jq -r '.[].VulnerabilityID' vulnerabilities-old.json | sort)
NEW_IDS=$(jq -r '.[].VulnerabilityID' vulnerabilities-new.json | sort)
if [ "$OLD_IDS" = "$NEW_IDS" ]; then
echo "The same vulnerabilities exist. No fix available yet."
else
echo "Different vulnerabilities detected. This might be a transient issue."
fi
else
echo "⚠ More vulnerabilities found in new image!"
echo "should_publish=false" >> $GITHUB_OUTPUT
fi
- name: Generate comparison report
id: report
run: |
echo "# Vulnerability Scan Comparison Report" > comparison-report.md
echo "" >> comparison-report.md
echo "**Scan Date**: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> comparison-report.md
echo "**Image Version**: ${{ steps.version.outputs.version }}" >> comparison-report.md
echo "" >> comparison-report.md
OLD_COUNT=${{ steps.scan_new.outputs.old_vuln_count }}
NEW_COUNT=${{ steps.scan_new.outputs.new_vuln_count }}
echo "## Summary" >> comparison-report.md
echo "- **Old Image**: ${OLD_COUNT} vulnerabilities" >> comparison-report.md
echo "- **New Image**: ${NEW_COUNT} vulnerabilities" >> comparison-report.md
if [ "$NEW_COUNT" -lt "$OLD_COUNT" ]; then
FIXED=$((OLD_COUNT - NEW_COUNT))
echo "- **Status**: ✅ ${FIXED} vulnerabilities fixed" >> comparison-report.md
elif [ "$NEW_COUNT" -eq "$OLD_COUNT" ]; then
echo "- **Status**: ⚠️ No vulnerabilities fixed" >> comparison-report.md
else
echo "- **Status**: ❌ More vulnerabilities in new image" >> comparison-report.md
fi
echo "" >> comparison-report.md
echo "## Fixed Vulnerabilities" >> comparison-report.md
# Find fixed vulnerabilities (in old but not in new)
OLD_IDS=$(jq -r '.[].VulnerabilityID' vulnerabilities-old.json)
FIXED_VULNS=""
for vuln_id in $OLD_IDS; do
if ! jq -e --arg id "$vuln_id" '.[] | select(.VulnerabilityID == $id)' vulnerabilities-new.json > /dev/null 2>&1; then
VULN_DETAILS=$(jq -r --arg id "$vuln_id" '.[] | select(.VulnerabilityID == $id) | "- [\(.Severity)] `\(.VulnerabilityID)` in \(.PkgName) \(.InstalledVersion)"' vulnerabilities-old.json)
echo "$VULN_DETAILS" >> comparison-report.md
FIXED_VULNS="true"
fi
done
if [ -z "$FIXED_VULNS" ]; then
echo "None" >> comparison-report.md
fi
echo "" >> comparison-report.md
echo "## Remaining Vulnerabilities" >> comparison-report.md
if [ "$NEW_COUNT" -gt 0 ]; then
jq -r '.[] | "- [\(.Severity)] `\(.VulnerabilityID)` in \(.PkgName) \(.InstalledVersion) (fix available: \(.FixedVersion // "no"))"' vulnerabilities-new.json >> comparison-report.md
else
echo "None" >> comparison-report.md
fi
cat comparison-report.md
- name: Publish Docker image if vulnerabilities were fixed
id: build-and-push
if: steps.scan_new.outputs.should_publish == 'true'
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
ghcr.io/${{ needs.scan.outputs.repo_lower }}:latest
ghcr.io/${{ needs.scan.outputs.repo_lower }}:${{ steps.version.outputs.version }}
ghcr.io/${{ needs.scan.outputs.repo_lower }}:${{ steps.version.outputs.major_minor }}
ghcr.io/${{ needs.scan.outputs.repo_lower }}:${{ steps.version.outputs.major }}
labels: |
org.opencontainers.image.title=${{ needs.scan.outputs.repo_lower }}
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ github.event.repository.updated_at }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
# Force pull fresh base images
pull: true
no-cache: true
- name: Sign the published Docker image
if: steps.scan_new.outputs.should_publish == 'true'
env:
DIGEST: ${{ steps.build-and-push.outputs.digest }}
run: |
echo "Signing image with digest: ${DIGEST}"
# Sign all tags
for tag in latest ${{ steps.version.outputs.version }} ${{ steps.version.outputs.major_minor }} ${{ steps.version.outputs.major }}; do
echo "Signing ghcr.io/${{ needs.scan.outputs.repo_lower }}:${tag}"
cosign sign --yes ghcr.io/${{ needs.scan.outputs.repo_lower }}:${tag}@${DIGEST}
done
- name: Comment on issue with results
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const issueTitle = '🔒 Security vulnerabilities detected in Docker base image';
const issues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: ['security', 'docker', 'automated']
});
const issue = issues.data.find(issue => issue.title === issueTitle);
if (!issue) {
console.log('No issue found to comment on');
return;
}
const report = fs.readFileSync('comparison-report.md', 'utf8');
const shouldPublish = '${{ steps.scan_new.outputs.should_publish }}' === 'true';
const newDigest = '${{ steps.build-and-push.outputs.digest }}';
let commentBody = `## Rebuild Analysis\n\n${report}\n\n`;
if (shouldPublish) {
commentBody += `## ✅ Docker Image Published\n\n`;
commentBody += `Vulnerabilities were fixed! The Docker image has been rebuilt and published.\n\n`;
commentBody += `**New Digest**: \`${newDigest}\`\n`;
commentBody += `**Tags Updated**: latest, ${{ steps.version.outputs.version }}, ${{ steps.version.outputs.major_minor }}, ${{ steps.version.outputs.major }}\n`;
commentBody += `**Published Date**: ${new Date().toISOString()}\n\n`;
commentBody += `The new image has been signed with cosign.\n\n`;
commentBody += `You may close this issue if satisfied with the results.`;
} else {
commentBody += `## ⚠️ Image Not Published\n\n`;
commentBody += `The rebuild did not fix any vulnerabilities, so the image was not published.\n\n`;
commentBody += `**Possible reasons:**\n`;
commentBody += `- The base image maintainers have not released fixes yet\n`;
commentBody += `- The vulnerabilities require application-level changes\n`;
commentBody += `- New vulnerabilities were introduced\n\n`;
commentBody += `This workflow will retry on the next scheduled run.`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: commentBody
});