Skip to content

Periodic Container Security Scan #26

Periodic Container Security Scan

Periodic Container Security Scan #26

name: Periodic Container Security Scan
on:
schedule:
- cron: '0 2 * * 1' # Weekly on Monday at 2am UTC
workflow_dispatch: # Allow manual trigger
permissions: {}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
discover-published-images:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
configs: ${{ steps.find-configs.outputs.configs }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Find all configuration files
id: find-configs
run: |
# Find all spec.yaml files - scan all published images
all_configs=$(find npx uvx go -name "spec.yaml" -type f 2>/dev/null | sort)
configs_json=$(echo "$all_configs" | jq -R -s -c 'split("\n")[:-1]')
echo "configs=$configs_json" >> $GITHUB_OUTPUT
echo "Found $(echo "$all_configs" | wc -l) configurations to scan"
scan-images:
needs: discover-published-images
runs-on: ubuntu-latest
if: ${{ needs.discover-published-images.outputs.configs != '[]' }}
strategy:
matrix:
config: ${{ fromJson(needs.discover-published-images.outputs.configs) }}
fail-fast: false
permissions:
contents: read
packages: read
security-events: write
issues: write # To create issues for critical findings
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install yq
uses: mikefarah/yq@751d8ad57b84f1794661bc70c0afb92a22ad7b3c # v4.53.2
- name: Extract metadata from config
id: meta
env:
CONFIG_FILE: ${{ matrix.config }}
run: |
config_file="$CONFIG_FILE"
protocol=$(echo "$config_file" | cut -d'/' -f1)
server_name=$(echo "$config_file" | cut -d'/' -f2)
echo "protocol=$protocol" >> $GITHUB_OUTPUT
echo "server_name=$server_name" >> $GITHUB_OUTPUT
# Extract version
spec_version=$(yq '.spec.version' "$config_file" 2>/dev/null || echo "")
if [ -n "$spec_version" ]; then
version="$spec_version"
else
version="latest"
fi
echo "version=$version" >> $GITHUB_OUTPUT
# Generate image name
image_name="${REGISTRY}/${IMAGE_NAME}/${protocol}/${server_name}"
echo "image_name=$image_name" >> $GITHUB_OUTPUT
echo "image_ref=${image_name}:${version}" >> $GITHUB_OUTPUT
- name: Log in to Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Grype vulnerability scan (SARIF)
id: grype-scan
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: "${{ steps.meta.outputs.image_ref }}"
severity-cutoff: "low"
output-format: "sarif"
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
if: always()
with:
sarif_file: ${{ steps.grype-scan.outputs.sarif }}
category: 'periodic-grype-${{ steps.meta.outputs.server_name }}'
- name: Run Grype vulnerability scan (JSON)
id: grype-scan-json
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: "${{ steps.meta.outputs.image_ref }}"
severity-cutoff: "low"
output-format: "json"
- name: Check for critical issues
id: check-critical
run: |
critical=$(jq '[.matches[]? | select(.vulnerability.severity == "Critical")] | length' ${{ steps.grype-scan-json.outputs.json }})
high=$(jq '[.matches[]? | select(.vulnerability.severity == "High")] | length' ${{ steps.grype-scan-json.outputs.json }})
echo "critical=$critical" >> $GITHUB_OUTPUT
echo "high=$high" >> $GITHUB_OUTPUT
if [ "$critical" -gt 0 ]; then
echo "should_create_issue=true" >> $GITHUB_OUTPUT
else
echo "should_create_issue=false" >> $GITHUB_OUTPUT
fi
- name: Create issue for critical findings
if: steps.check-critical.outputs.should_create_issue == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
GRYPE_JSON_PATH: ${{ steps.grype-scan-json.outputs.json }}
with:
script: |
const fs = require('fs');
const results = JSON.parse(fs.readFileSync(process.env.GRYPE_JSON_PATH, 'utf8'));
const critical = ${{ steps.check-critical.outputs.critical }};
const high = ${{ steps.check-critical.outputs.high }};
let body = `## 🚨 Security Scan Alert\n\n`;
body += `A periodic security scan found critical issues in the container image:\n\n`;
body += `- **Image**: \`${{ steps.meta.outputs.image_ref }}\`\n`;
body += `- **Critical vulnerabilities**: ${critical}\n`;
body += `- **High vulnerabilities**: ${high}\n\n`;
body += `### Details\n\n`;
body += `See the [Security tab](../../security/code-scanning) for full details.\n\n`;
if (critical > 0) {
body += `#### Critical Vulnerabilities\n\n`;
const criticalVulns = (results.matches || [])
.filter(m => m.vulnerability.severity === 'Critical')
.slice(0, 5);
for (const match of criticalVulns) {
body += `- **${match.vulnerability.id}** in \`${match.artifact.name}@${match.artifact.version}\`: ${match.vulnerability.description || 'No description'}\n`;
}
if (critical > 5) {
body += `\n_... and ${critical - 5} more. See Security tab for complete list._\n`;
}
}
body += `\n---\n`;
body += `_Automated security scan from [periodic-security-scan workflow](../actions/workflows/periodic-security-scan.yml)_`;
// Check if an issue already exists for this image
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'security,grype',
});
const existingIssue = issues.find(issue =>
issue.title.includes('${{ steps.meta.outputs.server_name }}')
);
if (existingIssue) {
// Update existing issue
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existingIssue.number,
body: `## Updated Scan Results\n\n${body}`
});
console.log(`Updated existing issue #${existingIssue.number}`);
} else {
// Create new issue
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `🚨 Security: Critical issues in ${{ steps.meta.outputs.server_name }} container`,
body: body,
labels: ['security', 'grype', 'critical']
});
console.log('Created new security issue');
}
- name: Upload scan results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: periodic-scan-${{ steps.meta.outputs.server_name }}
path: |
${{ steps.grype-scan-json.outputs.json }}
${{ steps.grype-scan.outputs.sarif }}
retention-days: 90
summary:
needs: scan-images
runs-on: ubuntu-latest
permissions: {}
if: always()
steps:
- name: Generate summary
run: |
echo "## Periodic Security Scan Complete" >> $GITHUB_STEP_SUMMARY
echo "- **Scan Type**: Vulnerability scan (Grype)" >> $GITHUB_STEP_SUMMARY
echo "- **Severity Levels**: CRITICAL, HIGH, MEDIUM, LOW" >> $GITHUB_STEP_SUMMARY
echo "- **Status**: ${{ needs.scan-images.result }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "View detailed results in the [Security tab](../../security/code-scanning)." >> $GITHUB_STEP_SUMMARY