diff --git a/.github/scripts/grype-slack-digest.sh b/.github/scripts/grype-slack-digest.sh
new file mode 100755
index 0000000..2ebe85d
--- /dev/null
+++ b/.github/scripts/grype-slack-digest.sh
@@ -0,0 +1,70 @@
+#!/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
+# //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
+#
+# Prints the JSON payload on stdout, or nothing when there are no findings.
+# 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 }"
+shopt -s nullglob
+files=("$dir"/*/grype.json)
+[ "${#files[@]}" -gt 0 ] || exit 0
+
+jq -n \
+ --arg run_url "${RUN_URL:-}" \
+ --arg ref "${REF:-}" \
+ --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
+ | if ($rows | length) == 0 then empty 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))}]}]
+ + ($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[@]}"
diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml
index 6ad3cb4..8f57b27 100644
--- a/.github/workflows/container-validation.yml
+++ b/.github/workflows/container-validation.yml
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -49,11 +53,51 @@ 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
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ - name: Download Grype reports
+ if: ${{ env.SLACK_WEBHOOK_URL != '' }}
+ uses: actions/download-artifact@v4
+ with:
+ pattern: grype-*
+ path: grype-reports
+ - name: Post digest to Slack
+ if: ${{ env.SLACK_WEBHOOK_URL != '' }}
+ env:
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ REF: ${{ github.head_ref || github.ref_name }}
+ run: |
+ payload="$(./.github/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 post
+ if: ${{ env.SLACK_WEBHOOK_URL == '' }}
+ run: echo "::notice::SLACK_WEBHOOK_URL is not available (unset, or a fork PR); skipping the Slack digest."
+
generator_checks:
name: 'Verify the Dockerfile generators (version/source-mode decoupling)'
runs-on: ubuntu-latest
diff --git a/.github/workflows/verify-image_rw.yml b/.github/workflows/verify-image_rw.yml
index 9866f90..c1dc34b 100644
--- a/.github/workflows/verify-image_rw.yml
+++ b/.github/workflows/verify-image_rw.yml
@@ -9,6 +9,10 @@ on:
dockerfile_path:
required: true
type: string
+ scan_label:
+ required: true
+ type: string
+ description: Unique label for this scan; names the uploaded grype report artifact.
image_tag:
required: true
type: string
@@ -37,4 +41,26 @@ jobs:
- name: Install Grype
run: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
- name: Scan vulnerabilities with Grype
- run: grype -o table --fail-on critical --only-fixed ${{ inputs.image_tag }}
+ # 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: |
+ rc=0
+ grype -o json=grype.json --fail-on critical --only-fixed ${{ inputs.image_tag }} || rc=$?
+ if [ -s 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.json | column -t -s "$(printf '\t')"
+ fi
+ exit "$rc"
+ - name: Upload Grype report
+ if: ${{ always() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: grype-${{ inputs.scan_label }}
+ path: grype.json
+ if-no-files-found: ignore
+ retention-days: 1
diff --git a/.grype.yaml b/.grype.yaml
index 04b4903..126b9eb 100644
--- a/.grype.yaml
+++ b/.grype.yaml
@@ -2,38 +2,21 @@ ignore:
- vulnerability: CVE-2025-27558 # Not able to fix it at the moment
# False positive: Grype's binary classifier reads the PHP interpreter's own version
# string (embedded in /usr/local/bin/php, libphp.so, and the bundled extensions such
- # as curl.so) as a "curl" binary, then flags these curl CVEs (all fixed in curl
- # 8.21.0). The REAL curl is the Debian package, patched (8.14.1-2+deb13u4) and
- # correctly not flagged. Scoped per-CVE to binary-classified curl so a genuine future
- # curl finding still surfaces instead of being blanket-ignored.
- - vulnerability: CVE-2026-11856
- package:
- name: curl
- type: binary
- - vulnerability: CVE-2026-10536
- package:
- name: curl
- type: binary
- - vulnerability: CVE-2026-8927
- package:
- name: curl
- type: binary
- - vulnerability: CVE-2026-8924
- package:
- name: curl
- type: binary
- # Same binary-classifier false positive as above, newly published CVE IDs.
- # Verified against simplerisk-minimal (php 8.3/8.4/8.5): the only "curl"
- # match is /usr/local/lib/php/extensions/.../curl.so reporting the PHP
- # version (e.g. 8.5.10) as its own. The real Debian curl package
- # (8.14.1-2+deb13u5) is also affected but Debian's tracker marks both IDs
- # "wont-fix", so --only-fixed already excludes that (legitimate) match on
- # its own; only the misclassified binary match needs ignoring here.
- - vulnerability: CVE-2026-19931
- package:
- name: curl
- type: binary
- - vulnerability: CVE-2026-18924
- package:
+ # as curl.so) as a "curl" binary, then flags every curl CVE fixed after that number
+ # (PHP 8.5.x is read as "curl 8.5.x"). The REAL curl is the Debian package
+ # (8.14.1-x), which grype scans separately as a deb. Scoped by package + location
+ # instead of per-CVE, so new curl CVEs no longer need a new entry each; a curl found
+ # anywhere outside PHP's extension directory (or as a deb) still surfaces.
+ - package:
name: curl
type: binary
+ location: "/usr/local/lib/php/extensions/**"
+ # False positive: the swagger-api/swagger-ui Composer package ships a release-script
+ # template at swagger-ui-dist-package/package.json whose version is the literal
+ # placeholder "$$VERSION". Grype cannot parse it, so it matches every swagger-ui-dist
+ # advisory. Nothing loads this file; the UI actually served is vendor/.../dist/
+ # (5.x, past the 4.1.3 fix).
+ - package:
+ name: swagger-ui-dist
+ type: npm
+ location: "/var/www/simplerisk/vendor/swagger-api/swagger-ui/swagger-ui-dist-package/**"
diff --git a/simplerisk/Dockerfile b/simplerisk/Dockerfile
index 55a7f51..2971463 100644
--- a/simplerisk/Dockerfile
+++ b/simplerisk/Dockerfile
@@ -41,6 +41,7 @@ RUN mkdir -p /configurations \
RUN dpkg-divert --local --rename /usr/bin/ischroot && \
ln -sf /bin/true /usr/bin/ischroot && \
apt-get update && \
+ DEBIAN_FRONTEND=noninteractive apt-get -y upgrade && \
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends apache2 \
php \
php-mysql \
diff --git a/simplerisk/generate_dockerfile.sh b/simplerisk/generate_dockerfile.sh
index 4cadb84..26d6c2c 100755
--- a/simplerisk/generate_dockerfile.sh
+++ b/simplerisk/generate_dockerfile.sh
@@ -77,6 +77,7 @@ RUN mkdir -p /configurations \\
RUN dpkg-divert --local --rename /usr/bin/ischroot && \\
ln -sf /bin/true /usr/bin/ischroot && \\
apt-get update && \\
+ DEBIAN_FRONTEND=noninteractive apt-get -y upgrade && \\
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends apache2 \\
php \\
php-mysql \\