From fbf7064af25bc6bfb06cb84a4ca3ec02ea5d7fcb Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Tue, 29 Sep 2026 14:42:15 -0500 Subject: [PATCH 1/4] fix: scope grype false-positive ignores by location (curl.so PHP version, swagger-ui-dist template) Co-Authored-By: Claude Sonnet 5.5 --- .grype.yaml | 49 ++++++++++++++++--------------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) 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/**" From bf787cf3d3aef4bb8f0064155ee05cff31f6723c Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Tue, 29 Sep 2026 14:48:35 -0500 Subject: [PATCH 2/4] fix: apt-get upgrade in full-stack image to pick up jammy-updates patches ubuntu:22.04 ships libc-bin 2.35-0ubuntu3.14; 3.15 (fixing 5 CVEs) is in jammy-updates. The minimal image already upgrades; the full-stack did not. Co-Authored-By: Claude Sonnet 5.5 --- simplerisk/Dockerfile | 1 + simplerisk/generate_dockerfile.sh | 1 + 2 files changed, 2 insertions(+) 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 \\ From 504c0aa4d78f4a33912cea892022db7d88ff50ac Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Tue, 29 Sep 2026 15:42:18 -0500 Subject: [PATCH 3/4] ci: sort grype results by package, then severity Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/verify-image_rw.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify-image_rw.yml b/.github/workflows/verify-image_rw.yml index 9866f90..183e033 100644 --- a/.github/workflows/verify-image_rw.yml +++ b/.github/workflows/verify-image_rw.yml @@ -37,4 +37,18 @@ 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" From f648131a284285adece205243d8a9c40d32a5722 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Tue, 29 Sep 2026 15:57:33 -0500 Subject: [PATCH 4/4] ci: post a per-run Grype findings digest to Slack Each scan job uploads its grype.json (1-day retention); a final job builds one de-duplicated digest (package, then severity) and posts it to the SLACK_WEBHOOK_URL webhook, skipping cleanly when the secret is absent (fork PRs). Co-Authored-By: Claude Sonnet 5.5 --- .github/scripts/grype-slack-digest.sh | 70 ++++++++++++++++++++++ .github/workflows/container-validation.yml | 44 ++++++++++++++ .github/workflows/verify-image_rw.yml | 12 ++++ 3 files changed, 126 insertions(+) create mode 100755 .github/scripts/grype-slack-digest.sh 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 183e033..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 @@ -52,3 +56,11 @@ jobs: ' 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