Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/actions/grype-report/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Grype report
description: >
Collect the grype-* artifacts of this run, write a one-row-per-image summary to the
job log and step summary, and post one de-duplicated digest to Slack when any image
has findings. Run it in a job with `if: always()` so a critical finding that failed a
scan job is still reported. Needs a prior checkout of this repository.

inputs:
results:
description: Newline-separated <label>=<job-result> pairs, one per expected scan.
required: true
slack_webhook_url:
description: Slack incoming webhook. When empty, the Slack post is skipped.
required: false
default: ''
always_post:
description: >
'true' posts the per-image table to Slack even when there are no findings, so a
quiet run still shows that its scans ran. Otherwise Slack only hears about findings.
required: false
default: 'false'
context:
description: Text shown in the Slack message (workflow name and branch).
required: false
default: ''

runs:
using: composite
steps:
- name: Download Grype reports
uses: actions/download-artifact@v4
with:
pattern: grype-*
path: grype-reports
# A scan that never produced a report has no artifact; the summary reports that
# as "did not complete" rather than failing here.
continue-on-error: true
- name: Summarize scan results
shell: bash
env:
RESULTS: ${{ inputs.results }}
run: |
mkdir -p grype-reports
mapfile -t pairs < <(printf '%s\n' "$RESULTS" | sed '/^[[:space:]]*$/d')
"${{ github.action_path }}/../../scripts/grype-scan-summary.sh" grype-reports "${pairs[@]}" \
| tee -a "$GITHUB_STEP_SUMMARY"
- name: Post digest to Slack
if: ${{ inputs.slack_webhook_url != '' }}
shell: bash
env:
SLACK_WEBHOOK_URL: ${{ inputs.slack_webhook_url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REF: ${{ inputs.context }}
RESULTS: ${{ inputs.results }}
ALWAYS_POST: ${{ inputs.always_post }}
run: |
if [ "$ALWAYS_POST" = "true" ]; then
mkdir -p grype-reports
mapfile -t pairs < <(printf '%s\n' "$RESULTS" | sed '/^[[:space:]]*$/d')
SUMMARY="$(SUMMARY_FORMAT=slack "${{ github.action_path }}/../../scripts/grype-scan-summary.sh" grype-reports "${pairs[@]}")"
export SUMMARY
fi
payload="$("${{ github.action_path }}/../../scripts/grype-slack-digest.sh" grype-reports)"
if [ -z "$payload" ]; then
echo "No Grype findings in any image; nothing to post."
exit 0
fi
curl -sSf -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL"
- name: Note skipped Slack post
if: ${{ inputs.slack_webhook_url == '' }}
shell: bash
run: echo "::notice::No Slack webhook available (secret unset, or a fork PR); skipping the Slack digest."
49 changes: 49 additions & 0 deletions .github/actions/grype-scan/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Grype scan
description: >
Scan one container image with Grype, print the findings sorted by package then
severity, and upload the JSON report (1-day retention) for the run's digest.
Fails on a critical finding, but the report is always uploaded first.

inputs:
image:
description: Image reference to scan (a local tag or a registry reference).
required: true
label:
description: Unique label for this scan; names the uploaded artifact grype-<label>.
required: true

runs:
using: composite
steps:
- name: Install Grype
shell: bash
run: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
- name: Scan vulnerabilities with Grype
shell: bash
env:
IMAGE: ${{ inputs.image }}
LABEL: ${{ inputs.label }}
# Grype's --sort-by takes a single strategy (package OR severity), so scan to
# JSON and print the table ourselves: package name, then severity (worst first),
# then vulnerability ID. The exit code still comes from grype's --fail-on.
run: |
mkdir -p "grype-out/$LABEL"
rc=0
grype -o "json=grype-out/$LABEL/grype.json" --fail-on critical --only-fixed "$IMAGE" || rc=$?
if [ -s "grype-out/$LABEL/grype.json" ]; then
jq -r '
def rank: {"Critical":0,"High":1,"Medium":2,"Low":3,"Negligible":4}[.] // 5;
(["PACKAGE","INSTALLED","FIXED IN","TYPE","VULNERABILITY","SEVERITY"] | @tsv),
(.matches | sort_by([.artifact.name, (.vulnerability.severity | rank), .vulnerability.id])[]
| [.artifact.name, .artifact.version, ((.vulnerability.fix.versions // []) | join(", ")), .artifact.type, .vulnerability.id, .vulnerability.severity] | @tsv)
' "grype-out/$LABEL/grype.json" | column -t -s "$(printf '\t')"
fi
exit "$rc"
- name: Upload Grype report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: grype-${{ inputs.label }}
path: grype-out/${{ inputs.label }}/grype.json
if-no-files-found: ignore
retention-days: 1
49 changes: 49 additions & 0 deletions .github/scripts/grype-scan-summary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Print a Markdown table with one row per scanned image, so a run always shows that
# every scan finished and what it found, including the all-clear case.
#
# Usage: grype-scan-summary.sh <dir> <label>=<job-result> ...
# <dir>/grype-<label>/grype.json report per image (download-artifact layout)
# <job-result> the scan job's needs.<job>.result
#
# Status per image:
# no report / job did not succeed -> scan did not complete (never shown as clean)
# report with no matches -> no findings
# report with matches -> findings, counted by severity
set -euo pipefail

dir="${1:?usage: $0 <dir> <label>=<result>...}"
shift

# SUMMARY_FORMAT=slack prints mrkdwn lines for the Slack digest instead of a table.
format="${SUMMARY_FORMAT:-markdown}"
if [ "$format" != "slack" ]; then
echo "### Grype scan results"
echo
echo "| Image | Result |"
echo "| --- | --- |"
fi
for pair in "$@"; do
label="${pair%%=*}"
result="${pair#*=}"
report="$dir/grype-$label/grype.json"
if [ ! -s "$report" ]; then
status="❌ scan did not complete (no report; job ${result})"
else
count="$(jq '.matches | length' "$report")"
if [ "$count" -eq 0 ]; then
status="✅ No findings"
else
detail="$(jq -r 'def rank: {"Critical":0,"High":1,"Medium":2,"Low":3,"Negligible":4}[.] // 5;
.matches | group_by(.vulnerability.severity)
| sort_by(.[0].vulnerability.severity | rank)
| map("\(length) \(.[0].vulnerability.severity)") | join(", ")' "$report")"
status="⚠️ ${count} findings (${detail})"
fi
fi
if [ "$format" = "slack" ]; then
echo "\`${label}\` ${status}"
else
echo "| \`${label}\` | ${status} |"
fi
done
86 changes: 86 additions & 0 deletions .github/scripts/grype-slack-digest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Build a Slack incoming-webhook payload from the grype JSON reports of one
# container-validation run.
#
# Usage: grype-slack-digest.sh <dir>
# <dir>/<grype-LABEL>/grype.json one report per scanned image (download-artifact layout)
#
# Env (all optional, used only for the message header/links):
# RUN_URL link to the workflow run REF branch or PR ref
#
# SUMMARY optional per-image status lines (from grype-scan-summary.sh, slack format).
# When set, a payload is produced even with no findings (the all-clear
# table), so a quiet run still shows that its scans ran.
#
# Prints the JSON payload on stdout, or nothing when there are no findings and no SUMMARY.
# Findings are de-duplicated across images (one line per package + vulnerability,
# listing every image it affects), sorted by package, then severity (worst first),
# then vulnerability ID, so the message reads as "what is vulnerable right now".
set -euo pipefail

dir="${1:?usage: $0 <dir>}"
shopt -s nullglob
files=("$dir"/*/grype.json)
[ "${#files[@]}" -gt 0 ] || [ -n "${SUMMARY:-}" ] || exit 0

jq -n \
--arg run_url "${RUN_URL:-}" \
--arg ref "${REF:-}" \
--arg summary "${SUMMARY:-}" \
--argjson max_blocks 45 \
--argjson max_chars 2900 '
def rank: {"Critical":0,"High":1,"Medium":2,"Low":3,"Negligible":4}[.] // 5;

def chunk($lines):
reduce $lines[] as $l ([""];
if (.[-1] | length) + ($l | length) + 1 > $max_chars then . + [$l]
else .[-1] += (if .[-1] == "" then "" else "\n" end) + $l
end);

[inputs
| (input_filename | split("/")[-2] | ltrimstr("grype-")) as $label
| .matches[]
| {pkg: .artifact.name, ver: .artifact.version,
fixed: ((.vulnerability.fix.versions // []) | join(", ")),
id: .vulnerability.id, sev: .vulnerability.severity,
url: (.vulnerability.dataSource // ""), label: $label}] as $rows
| ($summary | if . == "" then [] else [{type: "section", text: {type: "mrkdwn", text: .}}] end) as $summary_blocks
| if ($rows | length) == 0 then
(if $summary == "" then empty else {
text: "Grype scan results",
blocks: (
[{type: "header", text: {type: "plain_text", text: "Grype scan results"}},
{type: "context", elements: [{type: "mrkdwn",
text: ("`\($ref)`" + (if $run_url != "" then " · <\($run_url)|workflow run>" else "" end))}]}]
+ $summary_blocks)
} end)
else
($rows | group_by([.pkg, .id]) | map(. + [] | {
pkg: .[0].pkg, id: .[0].id, sev: .[0].sev, url: .[0].url,
ver: ([.[].ver] | unique | join(", ")),
fixed: ([.[].fixed] | unique | join(" | ")),
images: ([.[].label] | unique | join(", "))})
| sort_by([.pkg, (.sev | rank), .id])) as $vulns
| ($vulns | map(
"`\(.pkg)` \(.ver) → \(.fixed) · " +
(if .url != "" then "<\(.url)|\(.id)>" else .id end) +
" · *\(.sev)* · \(.images)")) as $lines
| chunk($lines) as $chunks
| ($vulns | group_by(.sev) | map({(.[0].sev): length}) | add) as $by_sev
| ($by_sev | to_entries | sort_by(.key | rank) | map("\(.value) \(.key)") | join(", ")) as $summary
| {
text: "Grype: \($vulns | length) vulnerabilities in \($rows | map(.label) | unique | length) image(s)",
blocks: (
[{type: "header", text: {type: "plain_text",
text: "Grype findings: \($vulns | length) unique (\($summary))"}},
{type: "context", elements: [{type: "mrkdwn",
text: ("`\($ref)`" + (if $run_url != "" then " · <\($run_url)|workflow run>" else "" end))}]}]
+ $summary_blocks
+ ($chunks[:$max_blocks] | map({type: "section", text: {type: "mrkdwn", text: .}}))
+ (if ($chunks | length) > $max_blocks
then [{type: "context", elements: [{type: "mrkdwn",
text: "List truncated; see the workflow run for the full report."}]}]
else [] end))
}
end
' ${files[@]+"${files[@]}"}
33 changes: 33 additions & 0 deletions .github/workflows/container-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
name: 'Verify simplerisk/simplerisk image based on Ubuntu 22.04 (Jammy)'
uses: ./.github/workflows/verify-image_rw.yml
with:
scan_label: "jammy"
context_path: "simplerisk/"
dockerfile_path: "simplerisk/Dockerfile"
image_tag: "simplerisk/simplerisk:testing"
Expand All @@ -22,6 +23,7 @@ jobs:
name: 'Verify simplerisk/simplerisk image based on Ubuntu 24.04 (Noble)'
uses: ./.github/workflows/verify-image_rw.yml
with:
scan_label: "noble"
context_path: "simplerisk/"
dockerfile_path: "simplerisk/Dockerfile"
image_tag: "simplerisk/simplerisk:testing"
Expand All @@ -31,6 +33,7 @@ jobs:
name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.3 with Apache'
uses: ./.github/workflows/verify-image_rw.yml
with:
scan_label: "minimal-php83"
context_path: "simplerisk-minimal/"
dockerfile_path: "simplerisk-minimal/Dockerfile"
image_tag: "simplerisk/simplerisk-minimal:testing"
Expand All @@ -40,6 +43,7 @@ jobs:
name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.4 with Apache'
uses: ./.github/workflows/verify-image_rw.yml
with:
scan_label: "minimal-php84"
context_path: "simplerisk-minimal/"
dockerfile_path: "simplerisk-minimal/Dockerfile"
image_tag: "simplerisk/simplerisk-minimal:testing"
Expand All @@ -49,11 +53,40 @@ jobs:
name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.5 with Apache'
uses: ./.github/workflows/verify-image_rw.yml
with:
scan_label: "minimal-php85"
context_path: "simplerisk-minimal/"
dockerfile_path: "simplerisk-minimal/Dockerfile"
image_tag: "simplerisk/simplerisk-minimal:testing"
build_args: "php_version=8.5\nPREGA_BUNDLE_FALLBACK=true"

grype_slack_digest:
name: 'Post Grype findings digest to Slack'
# always(): a critical finding fails its scan job, and that is exactly when the
# digest matters. Fork PRs get no secrets, so the post is skipped for them.
if: ${{ always() }}
needs:
- simplerisk-jammy
- simplerisk-noble
- simplerisk-minimal-php83
- simplerisk-minimal-php84
- simplerisk-minimal-php85
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Report Grype results
uses: ./.github/actions/grype-report
with:
slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}
context: ${{ github.workflow }} · ${{ github.head_ref || github.ref_name }}
always_post: 'true'
results: |
jammy=${{ needs.simplerisk-jammy.result }}
noble=${{ needs.simplerisk-noble.result }}
minimal-php83=${{ needs.simplerisk-minimal-php83.result }}
minimal-php84=${{ needs.simplerisk-minimal-php84.result }}
minimal-php85=${{ needs.simplerisk-minimal-php85.result }}

generator_checks:
name: 'Verify the Dockerfile generators (version/source-mode decoupling)'
runs-on: ubuntu-latest
Expand Down
Loading
Loading