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
58 changes: 58 additions & 0 deletions .github/actions/grype-report/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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: ''
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 }}
run: |
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
41 changes: 41 additions & 0 deletions .github/scripts/grype-scan-summary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/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

echo "### Grype scan results"
echo
echo "| Image | Result |"
echo "| --- | --- |"
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
echo "| \`${label}\` | ${status} |"
done
32 changes: 10 additions & 22 deletions .github/workflows/container-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,32 +71,20 @@ jobs:
- 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
- name: Report Grype results
uses: ./.github/actions/grype-report
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."
slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}
context: ${{ github.workflow }} · ${{ github.head_ref || github.ref_name }}
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)'
Expand Down
75 changes: 75 additions & 0 deletions .github/workflows/publish-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,37 @@ jobs:
cache-from: type=gha,scope=minimal-testing-php85
cache-to: type=gha,mode=max,scope=minimal-testing-php85

# Scan the images exactly as published, BEFORE the SSM promote below rolls them
# out to tier=testing customers. Each scan is continue-on-error so all three
# reports are produced and uploaded; the gate step then fails the job (and so
# skips the promote) if any of them hit a critical finding.
- name: Scan php83 (published)
id: scan83
continue-on-error: true
uses: ./.github/actions/grype-scan
with:
image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php83
label: published-minimal-php83
- name: Scan php84 (published)
id: scan84
continue-on-error: true
uses: ./.github/actions/grype-scan
with:
image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php84
label: published-minimal-php84
- name: Scan php85 (published)
id: scan85
continue-on-error: true
uses: ./.github/actions/grype-scan
with:
image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php85
label: published-minimal-php85
- name: Block the promote on critical findings
if: ${{ steps.scan83.outcome == 'failure' || steps.scan84.outcome == 'failure' || steps.scan85.outcome == 'failure' }}
run: |
echo "::error::Grype found critical vulnerabilities in the published images; not promoting to the testing tier. See the scan steps above."
exit 1

- name: Configure AWS credentials (OIDC → customers account)
uses: aws-actions/configure-aws-credentials@v4
with:
Expand Down Expand Up @@ -283,3 +314,47 @@ jobs:
${{ env.FULL_IMAGE_NAME }}:testing
cache-from: type=gha,scope=full-testing-noble
cache-to: type=gha,mode=max,scope=full-testing-noble

- name: Scan jammy (published)
id: scanjammy
continue-on-error: true
uses: ./.github/actions/grype-scan
with:
image: ${{ env.FULL_IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-jammy
label: published-full-jammy
- name: Scan noble (published)
id: scannoble
continue-on-error: true
uses: ./.github/actions/grype-scan
with:
image: ${{ env.FULL_IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-noble
label: published-full-noble
- name: Fail on critical findings
if: ${{ steps.scanjammy.outcome == 'failure' || steps.scannoble.outcome == 'failure' }}
run: |
echo "::error::Grype found critical vulnerabilities in the published full-stack images."
exit 1

grype_report:
name: Report Grype results for the published images
# always(): a critical finding fails a publish job (and blocks the promote), and
# that is exactly when the report matters.
if: ${{ always() }}
needs: [publish, publish-full]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout (docker@testing)
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.ref_name }}
results: |
published-minimal-php83=${{ needs.publish.result }}
published-minimal-php84=${{ needs.publish.result }}
published-minimal-php85=${{ needs.publish.result }}
published-full-jammy=${{ needs.publish-full.result }}
published-full-noble=${{ needs.publish-full.result }}
56 changes: 56 additions & 0 deletions .github/workflows/scheduled-grype-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Weekly Grype scan of the published testing images

# Images do not change between publishes, but the vulnerability database does. This
# scans the CURRENT published :testing images once a week so a CVE disclosed after
# publish still reaches the Slack channel. It is the only scan that notices that.

on:
schedule:
- cron: '0 13 * * 1' # Mondays 13:00 UTC
workflow_dispatch:

permissions:
contents: read

jobs:
scan-minimal:
name: 'Scan simplerisk/simplerisk-minimal:testing'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Scan vulnerabilities with Grype
uses: ./.github/actions/grype-scan
with:
image: simplerisk/simplerisk-minimal:testing
label: testing-minimal

scan-full:
name: 'Scan simplerisk/simplerisk:testing'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Scan vulnerabilities with Grype
uses: ./.github/actions/grype-scan
with:
image: simplerisk/simplerisk:testing
label: testing-full

report:
name: 'Report Grype results'
# always(): a critical finding fails a scan job, and that is when this matters.
if: ${{ always() }}
needs: [scan-minimal, scan-full]
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 }} · testing
results: |
testing-minimal=${{ needs.scan-minimal.result }}
testing-full=${{ needs.scan-full.result }}
27 changes: 3 additions & 24 deletions .github/workflows/verify-image_rw.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,29 +38,8 @@ jobs:
image: ${{ inputs.image_tag }}
failure-threshold: FATAL
dockle-host: "unix:///var/run/docker.sock"
- 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
# 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
uses: ./.github/actions/grype-scan
with:
name: grype-${{ inputs.scan_label }}
path: grype.json
if-no-files-found: ignore
retention-days: 1
image: ${{ inputs.image_tag }}
label: ${{ inputs.scan_label }}
Loading