n8n/.github/workflows/security-trivy-scan-callable.yml

344 lines
16 KiB
YAML

name: Security - Scan Docker Image With Trivy
on:
workflow_dispatch:
inputs:
image_ref:
description: 'Full image reference to scan e.g. ghcr.io/n8n-io/n8n:latest'
required: true
default: 'ghcr.io/n8n-io/n8n:latest'
workflow_call:
inputs:
image_ref:
type: string
description: 'Full image reference to scan e.g. ghcr.io/n8n-io/n8n:latest'
required: true
secrets:
QBOT_SLACK_TOKEN:
required: true
permissions:
contents: read
env:
QBOT_SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
SLACK_CHANNEL_ID: C0A6Y62BH9T #notify-security-scan-outputs
SLACK_CHANNEL_CRITICAL: C042WDXPTEZ #mission-security
jobs:
security_scan:
name: Security - Scan Docker Image With Trivy
runs-on: ubuntu-latest
steps:
- name: Checkout for VEX file
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
sparse-checkout: |
security/vex.openvex.json
security/trivy.yaml
security/trivy-ignore-policy.rego
sparse-checkout-cone-mode: false
- name: Pull Docker image with retry
run: |
for i in {1..4}; do
docker pull "${{ inputs.image_ref }}" && break
[ "$i" -lt 4 ] && echo "Retry $i failed, waiting..." && sleep 15
done
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # v0.34.1
id: trivy_scan
with:
image-ref: ${{ inputs.image_ref }}
format: 'json'
output: 'trivy-results.json'
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
ignore-unfixed: false
exit-code: '0'
trivy-config: 'security/trivy.yaml'
- name: Calculate vulnerability counts
id: process_results
run: |
if [ ! -s trivy-results.json ] || [ "$(jq '.Results | length' trivy-results.json)" -eq 0 ]; then
echo "No vulnerabilities found."
echo "vulnerabilities_found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Calculate counts by severity
CRITICAL_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length)' trivy-results.json)
HIGH_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length)' trivy-results.json)
MEDIUM_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "MEDIUM")] | length)' trivy-results.json)
LOW_COUNT=$(jq '([.Results[]?.Vulnerabilities[]? | select(.Severity == "LOW")] | length)' trivy-results.json)
TOTAL_VULNS=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT + LOW_COUNT))
# Get unique CVE count
UNIQUE_CVES=$(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | length' trivy-results.json)
# Get affected packages count
AFFECTED_PACKAGES=$(jq -r '[.Results[]?.Vulnerabilities[]? | .PkgName] | unique | length' trivy-results.json)
{
echo "vulnerabilities_found=$( [ "$TOTAL_VULNS" -gt 0 ] && echo 'true' || echo 'false' )"
echo "total_count=$TOTAL_VULNS"
echo "critical_count=$CRITICAL_COUNT"
echo "high_count=$HIGH_COUNT"
echo "medium_count=$MEDIUM_COUNT"
echo "low_count=$LOW_COUNT"
echo "unique_cves=$UNIQUE_CVES"
echo "affected_packages=$AFFECTED_PACKAGES"
} >> "$GITHUB_OUTPUT"
- name: Generate GitHub Job Summary
if: always()
run: |
{
echo "# 🛡️ Trivy Security Scan Results"
echo ""
echo "**Image:** \`${{ inputs.image_ref }}\`"
echo "**Scan Date:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.process_results.outputs.vulnerabilities_found }}" == "false" ]; then
{
echo "✅ **No vulnerabilities found!**"
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## 📊 Summary"
echo "| Metric | Count |"
echo "|--------|-------|"
echo "| 🔴 Critical Vulnerabilities | ${{ steps.process_results.outputs.critical_count }} |"
echo "| 🟠 High Vulnerabilities | ${{ steps.process_results.outputs.high_count }} |"
echo "| 🟡 Medium Vulnerabilities | ${{ steps.process_results.outputs.medium_count }} |"
echo "| 🟡 Low Vulnerabilities | ${{ steps.process_results.outputs.low_count }} |"
echo "| 📋 Unique CVEs | ${{ steps.process_results.outputs.unique_cves }} |"
echo "| 📦 Affected Packages | ${{ steps.process_results.outputs.affected_packages }} |"
echo ""
echo "## 🚨 Top Vulnerabilities"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
{
# Generate detailed vulnerability table
jq -r --arg image_ref "${{ inputs.image_ref }}" '
# Collect all vulnerabilities
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[]] |
# Group by CVE ID to avoid duplicates
group_by(.VulnerabilityID) |
map({
cve: .[0].VulnerabilityID,
severity: .[0].Severity,
cvss: (.[0].CVSS.nvd.V3Score // "N/A"),
cvss_sort: (.[0].CVSS.nvd.V3Score // 0),
packages: [.[] | "\(.PkgName)@\(.InstalledVersion)"] | unique | join(", "),
fixed: (.[0].FixedVersion // "No fix available"),
description: (.[0].Description // "No description available") | split("\n")[0] | .[0:150]
}) |
# Sort by severity (CRITICAL, HIGH, MEDIUM, LOW) and CVSS score
sort_by(
if .severity == "CRITICAL" then 0
elif .severity == "HIGH" then 1
elif .severity == "MEDIUM" then 2
elif .severity == "LOW" then 3
else 4 end,
-.cvss_sort
) |
# Take top 15
.[:15] |
# Generate markdown table
"| CVE | Severity | CVSS | Package(s) | Fix Version | Description |",
"|-----|----------|------|------------|-------------|-------------|",
(.[] | "| [\(.cve)](https://nvd.nist.gov/vuln/detail/\(.cve)) | \(.severity) | \(.cvss) | `\(.packages)` | `\(.fixed)` | \(.description) |")
' trivy-results.json
echo ""
echo "---"
echo "🔍 **View detailed logs above for full analysis**"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Generate Slack Blocks JSON
if: steps.process_results.outputs.vulnerabilities_found == 'true'
id: generate_blocks
run: |
BLOCKS_JSON=$(jq -c --arg image_ref "${{ inputs.image_ref }}" \
--arg repo_url "${{ github.server_url }}/${{ github.repository }}" \
--arg repo_name "${{ github.repository }}" \
--arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg critical_count "${{ steps.process_results.outputs.critical_count }}" \
--arg high_count "${{ steps.process_results.outputs.high_count }}" \
--arg medium_count "${{ steps.process_results.outputs.medium_count }}" \
--arg low_count "${{ steps.process_results.outputs.low_count }}" \
--arg unique_cves "${{ steps.process_results.outputs.unique_cves }}" \
'
# Function to create a vulnerability block with emoji indicators
def vuln_block: {
"type": "section",
"text": {
"type": "mrkdwn",
"text": "\(if .Severity == "CRITICAL" then ":red_circle:" elif .Severity == "HIGH" then ":large_orange_circle:" elif .Severity == "MEDIUM" then ":large_yellow_circle:" else ":large_green_circle:" end) *<https://nvd.nist.gov/vuln/detail/\(.VulnerabilityID)|\(.VulnerabilityID)>* (CVSS: `\(.CVSS.nvd.V3Score // "N/A")`)\n*Package:* `\(.PkgName)@\(.InstalledVersion)` → `\(.FixedVersion // "No fix available")`"
}
};
# Main structure
[
{
"type": "header",
"text": { "type": "plain_text", "text": ":warning: Trivy Scan: Vulnerabilities Detected" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Repository:*\n<\($repo_url)|\($repo_name)>" },
{ "type": "mrkdwn", "text": "*Image:*\n`\($image_ref)`" },
{ "type": "mrkdwn", "text": "*Critical:*\n:red_circle: \($critical_count)" },
{ "type": "mrkdwn", "text": "*High:*\n:large_orange_circle: \($high_count)" },
{ "type": "mrkdwn", "text": "*Medium:*\n:large_yellow_circle: \($medium_count)" },
{ "type": "mrkdwn", "text": "*Low:*\n:large_green_circle: \($low_count)" }
]
},
{
"type": "context",
"elements": [
{ "type": "mrkdwn", "text": ":shield: \($unique_cves) unique CVEs affecting packages" }
]
},
{ "type": "divider" }
] +
(
# Group vulnerabilities by CVE to avoid duplicates in notification
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[]] |
group_by(.VulnerabilityID) |
map(.[0]) |
sort_by(
(if .Severity == "CRITICAL" then 0
elif .Severity == "HIGH" then 1
elif .Severity == "MEDIUM" then 2
elif .Severity == "LOW" then 3
else 4 end),
-((.CVSS.nvd.V3Score // 0) | tonumber? // 0)
) |
.[:8] |
map(. | vuln_block)
) +
[
{ "type": "divider" },
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": ":github: View Full Report" },
"style": "primary",
"url": $run_url
}
]
}
]
' trivy-results.json)
echo "slack_blocks=$BLOCKS_JSON" >> "$GITHUB_OUTPUT"
- name: Send Slack Notification
if: steps.process_results.outputs.vulnerabilities_found == 'true'
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
channel: ${{ env.SLACK_CHANNEL_ID }}
text: "🚨 Trivy Scan: ${{ steps.process_results.outputs.critical_count }} Critical, ${{ steps.process_results.outputs.high_count }} High, ${{ steps.process_results.outputs.medium_count }} Medium, ${{ steps.process_results.outputs.low_count }} Low vulnerabilities found in ${{ inputs.image_ref }}"
blocks: ${{ steps.generate_blocks.outputs.slack_blocks }}
- name: Generate Critical Vulnerability Blocks for Mission Security
if: steps.process_results.outputs.vulnerabilities_found == 'true' && steps.process_results.outputs.critical_count != '0'
id: generate_critical_blocks
run: |
CRITICAL_BLOCKS_JSON=$(jq -c --arg image_ref "${{ inputs.image_ref }}" \
--arg repo_url "${{ github.server_url }}/${{ github.repository }}" \
--arg repo_name "${{ github.repository }}" \
--arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--arg critical_count "${{ steps.process_results.outputs.critical_count }}" \
--arg unique_cves "${{ steps.process_results.outputs.unique_cves }}" \
'
# Function to create a detailed critical vulnerability block
def critical_vuln_block:
# Build references section if available
(if (.References | length) > 0 then
"\n*References:* " + ([.References[0:2][] | "<\(.)|\(. | split("/")[-1])>"] | join(", ")) + (if (.References | length) > 2 then " and \((.References | length) - 2) more" else "" end)
else "" end) as $refs |
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":red_circle: *<https://nvd.nist.gov/vuln/detail/\(.VulnerabilityID)|\(.VulnerabilityID)>*\n*Severity:* CRITICAL | *CVSS Score:* `\(.CVSS.nvd.V3Score // "N/A")` | *Vector:* `\(.CVSS.nvd.V3Vector // "N/A")`\n*Package:* `\(.PkgName)@\(.InstalledVersion)` → `\(.FixedVersion // "No fix available")`\n*Published:* \(.PublishedDate // "Unknown" | split("T")[0])\n*Description:* \(.Description // "No description available" | split("\n")[0] | .[0:200])\($refs)"
}
};
# Main structure for critical vulnerabilities
[
{
"type": "header",
"text": { "type": "plain_text", "text": ":rotating_light: CRITICAL Vulnerabilities Detected" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Repository:*\n<\($repo_url)|\($repo_name)>" },
{ "type": "mrkdwn", "text": "*Image:*\n`\($image_ref)`" },
{ "type": "mrkdwn", "text": "*Critical Vulnerabilities:*\n:red_circle: \($critical_count)" },
{ "type": "mrkdwn", "text": "*Scan Time:*\n<!date^\(now | floor)^{date_num} {time_secs}|Unknown>" }
]
},
{ "type": "divider" },
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*:warning: Critical CVE Details:*"
}
}
] +
(
# Filter only CRITICAL vulnerabilities
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[] | select(.Severity == "CRITICAL")] |
# Group by CVE to avoid duplicates
group_by(.VulnerabilityID) |
map(.[0]) |
# Sort by CVSS score descending
sort_by(-((.CVSS.nvd.V3Score // 0) | tonumber? // 0)) |
# Include all critical vulnerabilities
map(. | critical_vuln_block)
) +
[
{ "type": "divider" },
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": ":github: View Full Scan Report" },
"style": "danger",
"url": $run_url
}
]
}
]
' trivy-results.json)
echo "critical_blocks=$CRITICAL_BLOCKS_JSON" >> "$GITHUB_OUTPUT"
- name: Send Critical Vulnerability Notification to Mission Security
if: steps.process_results.outputs.critical_count != '0'
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
with:
method: chat.postMessage
token: ${{ secrets.QBOT_SLACK_TOKEN }}
payload: |
channel: ${{ env.SLACK_CHANNEL_CRITICAL }}
text: "🚨 CRITICAL: ${{ steps.process_results.outputs.critical_count }} critical vulnerabilities found in ${{ inputs.image_ref }}"
blocks: ${{ steps.generate_critical_blocks.outputs.critical_blocks }}