diff --git a/.github/actions/scan-image/action.yml b/.github/actions/scan-image/action.yml index 7692816..b2fc364 100644 --- a/.github/actions/scan-image/action.yml +++ b/.github/actions/scan-image/action.yml @@ -5,8 +5,8 @@ description: > Optionally fail on a severity threshold. # One definition for both workflows, so the badge and the PR summary measure the -# same thing. Non-gating by default: a Trivy-based gate is blind to packages we -# build with melange. +# same thing. Non-gating by default; the two scanners still disagree on totals, +# because Trivy is run with ignore-unfixed and Grype is not. inputs: image: @@ -49,6 +49,9 @@ runs: # below deliberately includes unfixed, hence its larger totals. - name: Trivy scan uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + # https://trivy.dev/docs/latest/guide/scanner/vulnerability/#detection-priority + TRIVY_DETECTION_PRIORITY: comprehensive with: image-ref: ${{ inputs.tar == '' && inputs.image || '' }} input: ${{ inputs.tar }} @@ -84,7 +87,7 @@ runs: set -euo pipefail # Shared with the badge step, so the two cannot diverge. Requires # actions/checkout, which a repo-local action needs regardless. - . "${GITHUB_WORKSPACE}/.github/scripts/cve-counts.sh" + . "${GITHUB_WORKSPACE}/.github/scripts/cve-lib.sh" cve_counts trivy.json grype.json critical=$(( CVE_TRIVY_CRITICAL > CVE_GRYPE_CRITICAL ? CVE_TRIVY_CRITICAL : CVE_GRYPE_CRITICAL )) diff --git a/.github/scripts/cve-badge.sh b/.github/scripts/cve-badge.sh deleted file mode 100755 index 01e4bab..0000000 --- a/.github/scripts/cve-badge.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# -# Turn Trivy + Grype JSON reports into the two shields.io *endpoint* badge files -# the README renders for one image. The nightly commits them to .github/badges/. -# -# Usage: scripts/cve-badge.sh [out-dir] -# -# Requires: jq -set -euo pipefail - -if [ $# -lt 3 ]; then - echo "Usage: $0 [out-dir]" >&2 - exit 2 -fi - -TRIVY_JSON="$1" -GRYPE_JSON="$2" -IMAGE_NAME="$3" -OUT_DIR="${4:-badges}" - -for f in "${TRIVY_JSON}" "${GRYPE_JSON}"; do - [ -f "${f}" ] || { echo "report not found: ${f}" >&2; exit 2; } -done - -mkdir -p "${OUT_DIR}" - -# Counting lives in cve-counts.sh so the badge and the PR summary can never -# report different numbers for the same report. -# shellcheck source-path=SCRIPTDIR -# shellcheck source=./cve-counts.sh -. "$(dirname "${BASH_SOURCE[0]}")/cve-counts.sh" - -cve_counts "${TRIVY_JSON}" "${GRYPE_JSON}" -trivy_total="${CVE_TRIVY_TOTAL}"; trivy_high="${CVE_TRIVY_HIGH}" -grype_total="${CVE_GRYPE_TOTAL}"; grype_high="${CVE_GRYPE_HIGH}" - -[ "${trivy_high}" = "0" ] && trivy_color="brightgreen" || trivy_color="orange" -[ "${grype_high}" = "0" ] && grype_color="brightgreen" || grype_color="orange" - -emit() { - jq -n \ - --arg label "$2" \ - --arg msg "$3 high / $4 total" \ - --arg color "$5" \ - --arg logo "$6" \ - '{schemaVersion:1, label:$label, message:$msg, color:$color, namedLogo:$logo}' \ - > "$1" -} - -emit "${OUT_DIR}/${IMAGE_NAME}-trivy.json" "trivy CVEs" \ - "${trivy_high}" "${trivy_total}" "${trivy_color}" "aquasec" -emit "${OUT_DIR}/${IMAGE_NAME}-grype.json" "grype CVEs" \ - "${grype_high}" "${grype_total}" "${grype_color}" "anchore" - -echo "${IMAGE_NAME}: trivy ${trivy_high} high / ${trivy_total} total, grype ${grype_high} high / ${grype_total} total" diff --git a/.github/scripts/cve-counts.sh b/.github/scripts/cve-counts.sh deleted file mode 100644 index acd575e..0000000 --- a/.github/scripts/cve-counts.sh +++ /dev/null @@ -1,49 +0,0 @@ -# shellcheck shell=bash -# shellcheck disable=SC2034 # CVE_* are this library's interface, set for callers -# -# Shared CVE counting for the badge and the PR summary. Sourced, not executed. -# -# . cve-counts.sh; cve_counts trivy.json grype.json; echo "${CVE_TRIVY_HIGH}" -# -# Sets CVE_{TRIVY,GRYPE}_{CRITICAL,HIGH,TOTAL}; *_HIGH is critical+high. -# -# Requires: jq - -# A Trivy target with no findings has no .Vulnerabilities key, hence `// []` and -# `add // 0`. -_cve_trivy() { - jq --arg filter "$2" ' - [ .Results[]?.Vulnerabilities // [] - | map(select($filter == "" or (.Severity | ascii_upcase) == $filter)) - | length ] | add // 0 - ' "$1" -} - -# Compared case-insensitively: Trivy shouts, Grype title-cases. -_cve_grype() { - jq --arg filter "$2" ' - [ .matches[]? - | select($filter == "" or (.vulnerability.severity | ascii_upcase) == $filter) ] - | length - ' "$1" -} - -cve_counts() { - local trivy_json="$1" grype_json="$2" - - for f in "${trivy_json}" "${grype_json}"; do - [ -f "${f}" ] || { echo "report not found: ${f}" >&2; return 2; } - done - - CVE_TRIVY_CRITICAL="$(_cve_trivy "${trivy_json}" CRITICAL)" - local trivy_high_only - trivy_high_only="$(_cve_trivy "${trivy_json}" HIGH)" - CVE_TRIVY_HIGH=$(( CVE_TRIVY_CRITICAL + trivy_high_only )) - CVE_TRIVY_TOTAL="$(_cve_trivy "${trivy_json}" "")" - - CVE_GRYPE_CRITICAL="$(_cve_grype "${grype_json}" CRITICAL)" - local grype_high_only - grype_high_only="$(_cve_grype "${grype_json}" HIGH)" - CVE_GRYPE_HIGH=$(( CVE_GRYPE_CRITICAL + grype_high_only )) - CVE_GRYPE_TOTAL="$(_cve_grype "${grype_json}" "")" -} diff --git a/.github/scripts/cve-lib.sh b/.github/scripts/cve-lib.sh new file mode 100644 index 0000000..e06e4f0 --- /dev/null +++ b/.github/scripts/cve-lib.sh @@ -0,0 +1,176 @@ +# shellcheck shell=bash +# +# Everything the CVE badges, published reports and PR / job summaries share. +# Sourced, not executed. +# +# Trivy and Grype describe a finding differently. That difference lives in +# _CVE_NORMALIZE_* below and nowhere else: every consumer runs its jq through +# cve_jq and sees the same record, +# +# { sev, id, url, pkg, installed, fixed } +# +# so no renderer knows which scanner produced what it is formatting. +# +# . cve-lib.sh +# cve_counts trivy.json grype.json # -> CVE_TRIVY_*, CVE_GRYPE_* +# cve_jq grype.json grype 'map(.pkg) | unique | .[]' +# cve_summary_section trivy.json grype.json base-os +# +# cve_counts sets, for each of TRIVY and GRYPE: +# +# CVE__CRITICAL critical only +# CVE__HIGH_ONLY high only +# CVE__HIGH critical + high — what `fail-on: high` gates on +# CVE__MEDIUM medium only +# CVE__LOW low only +# CVE__TOTAL every severity, so it also covers Grype's negligible and +# unknown and is *not* the sum of the four above +# +# Requires: jq + +# A Trivy target with no findings has no .Vulnerabilities key, hence `// []`. +# Severities are upper-cased because Trivy shouts and Grype title-cases. +_CVE_NORMALIZE_trivy=' + [ .Results[]? | (.Vulnerabilities // [])[] + | { sev: (.Severity | ascii_upcase), + id: .VulnerabilityID, + url: (.PrimaryURL // ""), + pkg: (.PkgName // ""), + installed: (.InstalledVersion // ""), + fixed: (.FixedVersion // "") } ] +' + +_CVE_NORMALIZE_grype=' + [ .matches[]? + | { sev: (.vulnerability.severity | ascii_upcase), + id: .vulnerability.id, + url: (.vulnerability.dataSource // ""), + pkg: (.artifact.name // ""), + installed: (.artifact.version // ""), + fixed: ((.vulnerability.fix.versions // []) | join(", ")) } ] +' + +# In scope for every cve_jq program, so a severity looks the same in a badge, a +# PR comment and a published report. +# +# Markdown code spans need backticks, and a *pair* of them inside one quoted +# string reads as command substitution (SC2016) — so jq gets exactly one here, +# and the shell gets exactly one in CVE_BT below. +_CVE_PRELUDE=' + def bt: "`"; + def code: if . == null or . == "" then "—" else "\(bt)\(.)\(bt)" end; + def rank: + {"CRITICAL":0,"HIGH":1,"MEDIUM":2,"LOW":3,"NEGLIGIBLE":4,"UNKNOWN":5}[.] // 6; + def icon: + {"CRITICAL":":red_circle:","HIGH":":orange_circle:", + "MEDIUM":":yellow_circle:","LOW":":white_circle:"}[.] // ":black_circle:"; +' + +CVE_BT='`' + +# The two the shell renders itself, rather than through jq's `icon`. Same +# values — change them together. +CVE_ICON_CRITICAL=':red_circle:' +CVE_ICON_HIGH=':orange_circle:' + +# cve_jq +# +# Run over that report's normalised findings array (with the prelude +# in scope) and print the raw result. +cve_jq() { + local report="$1" kind="$2" program="$3" ref="_CVE_NORMALIZE_$2" + + [ -f "${report}" ] || { echo "report not found: ${report}" >&2; return 2; } + [ -n "${!ref:-}" ] || { echo "unknown scanner: ${kind}" >&2; return 2; } + + jq -r "${_CVE_PRELUDE} ${!ref} | ${program}" "${report}" +} + +_CVE_TALLY=' + [ .[].sev ] + | [ (map(select(. == "CRITICAL")) | length), + (map(select(. == "HIGH")) | length), + (map(select(. == "MEDIUM")) | length), + (map(select(. == "LOW")) | length), + length ] + | @tsv +' + +_cve_set_counts() { + local report="$1" kind="$2" up="${2^^}" tally c h m l t + + tally="$(cve_jq "${report}" "${kind}" "${_CVE_TALLY}")" || return $? + IFS=$'\t' read -r c h m l t <<<"${tally}" + + printf -v "CVE_${up}_CRITICAL" '%s' "${c}" + printf -v "CVE_${up}_HIGH_ONLY" '%s' "${h}" + printf -v "CVE_${up}_HIGH" '%s' "$(( c + h ))" + printf -v "CVE_${up}_MEDIUM" '%s' "${m}" + printf -v "CVE_${up}_LOW" '%s' "${l}" + printf -v "CVE_${up}_TOTAL" '%s' "${t}" +} + +cve_counts() { + _cve_set_counts "$1" trivy || return $? + _cve_set_counts "$2" grype || return $? +} + +# `2C / 5H / 12M / 3L` — the badge message, so a reader comparing a badge to a +# PR comment sees the same four numbers in the same order. +cve_chml() { + printf '%sC / %sH / %sM / %sL' "$1" "$2" "$3" "$4" +} + +# Criticals page someone, highs are a warning, and zero earns a tick. Medium and +# low stay bare counts — flagging them too would make every row look alarming. +_cve_flag() { + if [ "$1" -gt 0 ]; then printf '%s %s' "$2" "$1" + else printf ':white_check_mark: 0' + fi +} + +_cve_summary_row() { + printf '| %s | %s | %s | %s | %s | %s |\n' \ + "$1" \ + "$(_cve_flag "$2" "${CVE_ICON_CRITICAL}")" \ + "$(_cve_flag "$3" "${CVE_ICON_HIGH}")" \ + "$4" "$5" "$6" +} + +# One image's section, for a job summary or a PR comment. Both callers render it +# identically; they differ only in how many sections they print. The columns are +# the counts the README badge carries, so the two can never disagree. +cve_summary_section() { + local trivy_json="$1" grype_json="$2" label="$3" top + + cve_counts "${trivy_json}" "${grype_json}" || return $? + + printf '### %s%s%s\n\n' "${CVE_BT}" "${label}" "${CVE_BT}" + printf '| Scanner | Critical | High | Medium | Low | Total |\n' + printf '|---------|----------|------|--------|-----|-------|\n' + _cve_summary_row Trivy "${CVE_TRIVY_CRITICAL}" "${CVE_TRIVY_HIGH_ONLY}" \ + "${CVE_TRIVY_MEDIUM}" "${CVE_TRIVY_LOW}" "${CVE_TRIVY_TOTAL}" + _cve_summary_row Grype "${CVE_GRYPE_CRITICAL}" "${CVE_GRYPE_HIGH_ONLY}" \ + "${CVE_GRYPE_MEDIUM}" "${CVE_GRYPE_LOW}" "${CVE_GRYPE_TOTAL}" + + # A bare count would send the reader to the artifacts. + top="$(cve_jq "${grype_json}" grype ' + map(select(.sev == "CRITICAL" or .sev == "HIGH") | "\(.pkg) \(.installed)") + | group_by(.) | map({k: .[0], n: length}) | sort_by(-.n) | .[:8][] + | "| \(.k | code) | \(.n) |" + ')" || return $? + + if [ -n "${top}" ]; then + printf '\n
Grype critical+high by component\n\n' + printf '| Component | Findings |\n|-----------|----------|\n%s\n' "${top}" + printf '\n
\n' + fi +} + +# Printed once per summary, however many sections it holds. +cve_scanner_note() { + printf 'Trivy counts fixed vulnerabilities only; Grype includes unfixed, ' + printf 'so its totals run higher. Totals cover every severity, so they exceed ' + printf 'C+H+M+L where a scanner also reports negligible or unknown. Full ' + printf 'reports are in the workflow artifacts.\n' +} diff --git a/.github/scripts/cve-publish.sh b/.github/scripts/cve-publish.sh new file mode 100755 index 0000000..62d0582 --- /dev/null +++ b/.github/scripts/cve-publish.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# Render everything the `badges` branch serves for one image: a shields.io +# endpoint JSON per scanner, and the Markdown page that badge links to. +# +# Usage: scripts/cve-publish.sh [out-dir] +# +# Writes /-{trivy,grype}.{json,md}. One script rather than +# two because the README links each badge to its report: publishing a badge +# without its report leaves a live link on stale findings. +# +# Nothing here may embed a timestamp or a digest — publish-badges.sh commits +# only when a file actually changed, and a generated-at line would turn every +# nightly into a commit whether or not the findings moved. `git log` on that +# branch is the freshness record. +# +# Requires: jq +set -euo pipefail + +if [ $# -lt 3 ]; then + echo "Usage: $0 [out-dir]" >&2 + exit 2 +fi + +TRIVY_JSON="$1" +GRYPE_JSON="$2" +IMAGE_NAME="$3" +OUT_DIR="${4:-badges}" + +# shellcheck source-path=SCRIPTDIR +# shellcheck source=./cve-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/cve-lib.sh" + +# Counts come from the same place as the PR comment's, so a badge and a comment +# can never report different numbers for the same scan. +cve_counts "${TRIVY_JSON}" "${GRYPE_JSON}" + +mkdir -p "${OUT_DIR}" + +CVE_ROWS=' + def idcell: if .url == "" then (.id | code) else "[\(.id | code)](\(.url))" end; + sort_by((.sev | rank), .pkg, .id) + | map("| \(.sev | icon) \(.sev | ascii_downcase) | \(idcell) | \(.pkg | code) | \(.installed | code) | \(.fixed | code) |") + | join("\n") +' + +# Green still means "nothing a rebuild can clear at critical or high"; red only +# separates a critical from the rest, since it is the one that pages someone. +color_for() { + if [ "$1" -gt 0 ]; then echo red + elif [ "$2" -gt 0 ]; then echo orange + else echo brightgreen + fi +} + +count() { local n="CVE_${1^^}_$2"; printf '%s' "${!n}"; } + +for kind in trivy grype; do + case "${kind}" in + trivy) + report="${TRIVY_JSON}"; scanner=Trivy; logo=aquasec + note="Trivy runs with ${CVE_BT}--ignore-unfixed${CVE_BT}, so this lists only vulnerabilities a rebuild can clear — compare with the Grype report." + ;; + grype) + report="${GRYPE_JSON}"; scanner=Grype; logo=anchore + note="Grype includes unfixed vulnerabilities and every severity down to negligible, so its totals run higher than Trivy's." + ;; + esac + + critical="$(count "${kind}" CRITICAL)" + high="$(count "${kind}" HIGH_ONLY)" + medium="$(count "${kind}" MEDIUM)" + low="$(count "${kind}" LOW)" + total="$(count "${kind}" TOTAL)" + message="$(cve_chml "${critical}" "${high}" "${medium}" "${low}")" + + jq -n \ + --arg label "${kind}" \ + --arg msg "${message}" \ + --arg color "$(color_for "${critical}" "$(count "${kind}" HIGH)")" \ + --arg logo "${logo}" \ + '{schemaVersion:1, label:$label, message:$msg, color:$color, namedLogo:$logo}' \ + > "${OUT_DIR}/${IMAGE_NAME}-${kind}.json" + + rows="$(cve_jq "${report}" "${kind}" "${CVE_ROWS}")" + + { + printf '# %s — %s CVE report\n\n' "${CVE_BT}${IMAGE_NAME}${CVE_BT}" "${scanner}" + printf '\n\n' + printf '%s\n\n' "${note}" + printf "Refreshed by the [nightly build](../../actions/workflows/nightly.yml)" + printf " — see this branch's commit history for when.\n\n" + printf '| Critical | High | Medium | Low | Total |\n' + printf '|---------:|-----:|-------:|----:|------:|\n' + printf '| %s | %s | %s | %s | %s |\n\n' \ + "${critical}" "${high}" "${medium}" "${low}" "${total}" + + if [ -z "${rows}" ]; then + printf 'No vulnerabilities reported.\n' + else + printf '| Severity | ID | Package | Installed | Fixed in |\n' + printf '|----------|----|---------|-----------|----------|\n' + printf '%s\n' "${rows}" + fi + } > "${OUT_DIR}/${IMAGE_NAME}-${kind}.md" + + echo "${IMAGE_NAME} ${kind}: ${message} (${total} total)" +done diff --git a/.github/scripts/publish-badges.sh b/.github/scripts/publish-badges.sh index a05c802..47a4a7e 100755 --- a/.github/scripts/publish-badges.sh +++ b/.github/scripts/publish-badges.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash # -# Publish the shields.io endpoint JSON files to a dedicated branch of the same -# repo, so the README's badge URLs can point at a raw.githubusercontent.com URL -# on that branch without ever needing to push to `main` (which is protected). +# Publish the shields.io endpoint JSON files, and the Markdown reports each +# badge links to, to a dedicated branch of the same repo — so the README can +# point at raw.githubusercontent.com and github.com/blob URLs on that branch +# without ever needing to push to `main` (which is protected). # # The badge branch is treated as a state-only branch: on every run we resync to -# it (or create it orphan on first run), replace all *.json files with the fresh -# set, and commit only if something actually changed. Older images that no -# longer produce badges are dropped instead of lingering. +# it (or create it orphan on first run), replace all *.json and *.md files with +# the fresh set, and commit only if something actually changed. Older images +# that no longer produce badges are dropped instead of lingering. +# +# A run with badge JSON but no Markdown is accepted — the reports are an +# addition, and refusing would take the badges down with them. # # Usage: publish-badges.sh # @@ -30,9 +34,10 @@ BRANCH="$2" [ -d "${SRC}" ] || { echo "src-dir not found: ${SRC}" >&2; exit 2; } shopt -s nullglob -srcs=("${SRC}"/*.json) +badges=("${SRC}"/*.json) +srcs=("${badges[@]}" "${SRC}"/*.md) shopt -u nullglob -if [ "${#srcs[@]}" -eq 0 ]; then +if [ "${#badges[@]}" -eq 0 ]; then echo "no badge JSON files in ${SRC} — refusing to blank the branch" >&2 exit 2 fi @@ -49,8 +54,8 @@ git -C "${work}" remote add origin "${REMOTE_URL}" # on the fresh orphan branch created by `git init -b`. if git -C "${work}" fetch --depth=1 origin "${BRANCH}" 2>/dev/null; then git -C "${work}" reset --hard "origin/${BRANCH}" - # Drop every previous badge — a removed image must stop showing. - find "${work}" -maxdepth 1 -name '*.json' -delete + # Drop every previous badge and report — a removed image must stop showing. + find "${work}" -maxdepth 1 \( -name '*.json' -o -name '*.md' \) -delete fi cp "${srcs[@]}" "${work}/" diff --git a/.github/scripts/scan-report.sh b/.github/scripts/scan-report.sh index 71bd511..a23194f 100755 --- a/.github/scripts/scan-report.sh +++ b/.github/scripts/scan-report.sh @@ -8,6 +8,10 @@ # the layout download-artifact produces. Always emits something, so the sticky # comment refreshes instead of showing a stale run. # +# Per-image sections come from cve_summary_section, the same function the +# single-image scan-summary.sh calls, so a PR comment and the nightly job +# summary cannot describe the same scan differently. +# # Requires: jq set -euo pipefail @@ -16,7 +20,9 @@ TITLE="${2:-Container image CVE report}" [ -n "${SCANS_DIR}" ] || { echo "Usage: $0 [title]" >&2; exit 2; } -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=./cve-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cve-lib.sh" printf '## %s\n' "${TITLE}" @@ -33,7 +39,7 @@ for dir in "${SCANS_DIR}"/*/; do found=$((found + 1)) printf '\n' - SCAN_SUMMARY_FOOTER=0 "${SCRIPT_DIR}/scan-summary.sh" "${trivy}" "${grype}" "${label}" + cve_summary_section "${trivy}" "${grype}" "${label}" done if [ "${found}" = "0" ]; then @@ -41,5 +47,5 @@ if [ "${found}" = "0" ]; then exit 0 fi -printf '\nTrivy counts fixed vulnerabilities only; Grype includes unfixed, ' -printf 'so its totals run higher. Full reports are in the workflow artifacts.\n' +printf '\n' +cve_scanner_note diff --git a/.github/scripts/scan-summary.sh b/.github/scripts/scan-summary.sh index bfdf882..16e20eb 100755 --- a/.github/scripts/scan-summary.sh +++ b/.github/scripts/scan-summary.sh @@ -4,8 +4,8 @@ # # Usage: scripts/scan-summary.sh # -# Both scanners are shown because they legitimately disagree: Trivy cannot see -# packages we build with melange, Grype can. +# The section itself is cve_summary_section in cve-lib.sh, so this and the +# multi-image scan-report.sh render from one implementation. # # Requires: jq set -euo pipefail @@ -15,54 +15,10 @@ if [ $# -lt 3 ]; then exit 2 fi -TRIVY_JSON="$1" -GRYPE_JSON="$2" -LABEL="$3" - # shellcheck source-path=SCRIPTDIR -# shellcheck source=./cve-counts.sh -. "$(dirname "${BASH_SOURCE[0]}")/cve-counts.sh" - -cve_counts "${TRIVY_JSON}" "${GRYPE_JSON}" - -scan_summary_footer() { - printf '\nTrivy counts fixed vulnerabilities only; Grype includes unfixed, ' - printf 'so its totals run higher. Full reports are in the workflow artifacts.\n' -} - -verdict() { - if [ "$1" -gt 0 ]; then printf ':red_circle: %s' "$1" - elif [ "$2" -gt 0 ]; then printf ':warning: %s' "$2" - else printf ':white_check_mark: 0' - fi -} - -cat <Grype critical+high by component\n\n' - printf '| Component | Findings |\n|-----------|----------|\n%s\n' "${top}" - printf '\n\n' -fi - -# scan-report.sh sets this to 0 and prints the footer once for all images. -if [ "${SCAN_SUMMARY_FOOTER:-1}" != "0" ]; then - scan_summary_footer -fi +cve_summary_section "$1" "$2" "$3" +printf '\n' +cve_scanner_note diff --git a/.github/scripts/tests/test-cve-badge.sh b/.github/scripts/tests/test-cve-badge.sh deleted file mode 100755 index ac02d43..0000000 --- a/.github/scripts/tests/test-cve-badge.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# -# Fixture tests for scripts/cve-badge.sh. Plain bash + jq, no framework. -# Silent on success; prints only failures. Run via `make test`. -set -euo pipefail - -SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SCRIPT="${SCRIPTS}/cve-badge.sh" -TMP="$(mktemp -d)" -trap 'rm -rf "${TMP}"' EXIT - -checks=0 -fails=0 - -assert_eq() { - checks=$((checks + 1)) - if [ "$2" != "$3" ]; then - echo " FAIL $1: want '$2', got '$3'" >&2 - fails=$((fails + 1)) - fi -} - -field() { jq -r "$2" "$1"; } - -# Mixed severities, including a target with no .Vulnerabilities key at all. -cat > "${TMP}/trivy.json" <<'JSON' -{"Results":[ - {"Target":"a","Vulnerabilities":[{"Severity":"HIGH"},{"Severity":"LOW"}]}, - {"Target":"b","Vulnerabilities":[{"Severity":"CRITICAL"}]}, - {"Target":"c"} -]} -JSON -cat > "${TMP}/grype.json" <<'JSON' -{"matches":[ - {"vulnerability":{"severity":"High"}}, - {"vulnerability":{"severity":"Critical"}}, - {"vulnerability":{"severity":"Negligible"}} -]} -JSON -"${SCRIPT}" "${TMP}/trivy.json" "${TMP}/grype.json" demo "${TMP}/out" > /dev/null - -t="${TMP}/out/demo-trivy.json" -g="${TMP}/out/demo-grype.json" -assert_eq "trivy message" "2 high / 3 total" "$(field "${t}" .message)" -assert_eq "trivy color" "orange" "$(field "${t}" .color)" -assert_eq "trivy label" "trivy CVEs" "$(field "${t}" .label)" -assert_eq "trivy logo" "aquasec" "$(field "${t}" .namedLogo)" -assert_eq "trivy schema" "1" "$(field "${t}" .schemaVersion)" -assert_eq "grype message" "2 high / 3 total" "$(field "${g}" .message)" -assert_eq "grype color" "orange" "$(field "${g}" .color)" -assert_eq "grype logo" "anchore" "$(field "${g}" .namedLogo)" - -# Clean image. -echo '{"Results":[]}' > "${TMP}/trivy0.json" -echo '{"matches":[]}' > "${TMP}/grype0.json" -"${SCRIPT}" "${TMP}/trivy0.json" "${TMP}/grype0.json" clean "${TMP}/out" > /dev/null -assert_eq "clean trivy message" "0 high / 0 total" "$(field "${TMP}/out/clean-trivy.json" .message)" -assert_eq "clean trivy color" "brightgreen" "$(field "${TMP}/out/clean-trivy.json" .color)" -assert_eq "clean grype message" "0 high / 0 total" "$(field "${TMP}/out/clean-grype.json" .message)" -assert_eq "clean grype color" "brightgreen" "$(field "${TMP}/out/clean-grype.json" .color)" - -# Low/medium only must stay green — the badge tracks high+critical. -echo '{"Results":[{"Vulnerabilities":[{"Severity":"LOW"},{"Severity":"MEDIUM"}]}]}' > "${TMP}/trivy1.json" -echo '{"matches":[{"vulnerability":{"severity":"Low"}}]}' > "${TMP}/grype1.json" -"${SCRIPT}" "${TMP}/trivy1.json" "${TMP}/grype1.json" lowonly "${TMP}/out" > /dev/null -assert_eq "low-only trivy message" "0 high / 2 total" "$(field "${TMP}/out/lowonly-trivy.json" .message)" -assert_eq "low-only trivy color" "brightgreen" "$(field "${TMP}/out/lowonly-trivy.json" .color)" -assert_eq "low-only grype message" "0 high / 1 total" "$(field "${TMP}/out/lowonly-grype.json" .message)" - -# A missing report must fail, not report zero CVEs. -checks=$((checks + 1)) -if "${SCRIPT}" "${TMP}/nope.json" "${TMP}/grype0.json" x "${TMP}/out" >/dev/null 2>&1; then - echo " FAIL missing trivy report should exit non-zero" >&2 - fails=$((fails + 1)) -fi - -if [ "${fails}" -ne 0 ]; then - echo "cve-badge: ${fails}/${checks} failed" >&2 - exit 1 -fi -echo "cve-badge: ${checks} checks passed" diff --git a/.github/scripts/tests/test-cve-counts.sh b/.github/scripts/tests/test-cve-counts.sh deleted file mode 100755 index 602dad4..0000000 --- a/.github/scripts/tests/test-cve-counts.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# -# Tests for scripts/cve-counts.sh and scripts/scan-summary.sh. -# Silent on success; prints only failures. -set -euo pipefail - -SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TMP="$(mktemp -d)" -trap 'rm -rf "${TMP}"' EXIT - -checks=0 -fails=0 -assert_eq() { - checks=$((checks + 1)) - if [ "$2" != "$3" ]; then - echo " FAIL $1: want '$2', got '$3'" >&2 - fails=$((fails + 1)) - fi -} -fail() { fails=$((fails + 1)); echo " FAIL $1" >&2; } - -# shellcheck source-path=SCRIPTDIR -# shellcheck source=../cve-counts.sh -. "${SCRIPTS}/cve-counts.sh" - -# Mixed severities. The third Trivy target has no .Vulnerabilities key at all, -# which is the case that breaks a naive `add`. -cat > "${TMP}/trivy.json" <<'JSON' -{"Results":[ - {"Target":"a","Vulnerabilities":[{"Severity":"CRITICAL"},{"Severity":"HIGH"},{"Severity":"LOW"}]}, - {"Target":"b","Vulnerabilities":[{"Severity":"HIGH"},{"Severity":"MEDIUM"}]}, - {"Target":"c"} -]} -JSON -cat > "${TMP}/grype.json" <<'JSON' -{"matches":[ - {"vulnerability":{"severity":"Critical"},"artifact":{"name":"libfoo","version":"1.0"}}, - {"vulnerability":{"severity":"High"},"artifact":{"name":"libfoo","version":"1.0"}}, - {"vulnerability":{"severity":"High"},"artifact":{"name":"libbar","version":"2.0"}}, - {"vulnerability":{"severity":"Negligible"},"artifact":{"name":"libbaz","version":"3.0"}} -]} -JSON - -cve_counts "${TMP}/trivy.json" "${TMP}/grype.json" -assert_eq "trivy critical" "1" "${CVE_TRIVY_CRITICAL}" -assert_eq "trivy crit+high" "3" "${CVE_TRIVY_HIGH}" -assert_eq "trivy total" "5" "${CVE_TRIVY_TOTAL}" -assert_eq "grype critical" "1" "${CVE_GRYPE_CRITICAL}" -assert_eq "grype crit+high" "3" "${CVE_GRYPE_HIGH}" -assert_eq "grype total" "4" "${CVE_GRYPE_TOTAL}" - -# Clean reports must be zero, not empty or an error. -echo '{"Results":[]}' > "${TMP}/t0.json" -echo '{"matches":[]}' > "${TMP}/g0.json" -cve_counts "${TMP}/t0.json" "${TMP}/g0.json" -for v in CVE_TRIVY_CRITICAL CVE_TRIVY_HIGH CVE_TRIVY_TOTAL \ - CVE_GRYPE_CRITICAL CVE_GRYPE_HIGH CVE_GRYPE_TOTAL; do - assert_eq "clean ${v}" "0" "${!v}" -done - -# Severity spelling must not matter: Trivy shouts, Grype title-cases, and a -# scanner changing its mind should not silently zero the badge. -echo '{"Results":[{"Vulnerabilities":[{"Severity":"critical"}]}]}' > "${TMP}/tlc.json" -echo '{"matches":[{"vulnerability":{"severity":"CRITICAL"}}]}' > "${TMP}/glc.json" -cve_counts "${TMP}/tlc.json" "${TMP}/glc.json" -assert_eq "trivy lowercase severity" "1" "${CVE_TRIVY_CRITICAL}" -assert_eq "grype uppercase severity" "1" "${CVE_GRYPE_CRITICAL}" - -# A missing report must fail loudly rather than count as clean. -checks=$((checks + 1)) -if cve_counts "${TMP}/nope.json" "${TMP}/g0.json" 2>/dev/null; then - fail "missing report should return non-zero" -fi - -# --- scan-summary.sh -------------------------------------------------------- -out="$("${SCRIPTS}/scan-summary.sh" "${TMP}/trivy.json" "${TMP}/grype.json" demo-image)" -bt='`' # a literal backtick in a glob confuses shellcheck (SC2016) - -checks=$((checks + 1)) -case "${out}" in *"${bt}demo-image${bt}"*) ;; *) fail "summary must name the image" ;; esac -checks=$((checks + 1)) -case "${out}" in *"| Trivy |"*) ;; *) fail "summary must have a Trivy row" ;; esac -checks=$((checks + 1)) -case "${out}" in *"| Grype |"*) ;; *) fail "summary must have a Grype row" ;; esac -# Top-offender table is what makes it actionable. -checks=$((checks + 1)) -case "${out}" in *"${bt}libfoo 1.0${bt}"*) ;; *) fail "summary must list top components" ;; esac -checks=$((checks + 1)) -case "${out}" in *":red_circle:"*) ;; *) fail "criticals must be flagged red" ;; esac - -clean="$("${SCRIPTS}/scan-summary.sh" "${TMP}/t0.json" "${TMP}/g0.json" clean-image)" -checks=$((checks + 1)) -case "${clean}" in *":white_check_mark:"*) ;; *) fail "clean image must show a tick" ;; esac -checks=$((checks + 1)) -case "${clean}" in *":red_circle:"*) fail "clean image must not show red" ;; *) ;; esac - -# The badge script must agree with these counts — it is the same source now. -"${SCRIPTS}/cve-badge.sh" "${TMP}/trivy.json" "${TMP}/grype.json" agree "${TMP}/b" >/dev/null -assert_eq "badge reuses shared counts" "3 high / 5 total" \ - "$(jq -r .message "${TMP}/b/agree-trivy.json")" - -# cve-counts.sh is sourced, never executed: an exec bit on a file with no -# shebang trips the pre-commit hook, and a bulk chmod has already done it once. -checks=$((checks + 1)) -if [ -x "${SCRIPTS}/cve-counts.sh" ]; then - fail "cve-counts.sh is a sourced library and must not be executable" -fi - -if [ "${fails}" -ne 0 ]; then - echo "cve-counts: ${fails}/${checks} failed" >&2 - exit 1 -fi -echo "cve-counts: ${checks} checks passed" diff --git a/.github/scripts/tests/test-cve-lib.sh b/.github/scripts/tests/test-cve-lib.sh new file mode 100755 index 0000000..2838cce --- /dev/null +++ b/.github/scripts/tests/test-cve-lib.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# +# Tests for scripts/cve-lib.sh — the normaliser, the counts derived from it and +# the summary section every renderer shares. +# Silent on success; prints only failures. +set -euo pipefail + +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +# A pair of literal backticks in one string reads as command substitution +# (SC2016), and the expected strings here are Markdown code spans. +bt='`' + +checks=0 +fails=0 +assert_eq() { + checks=$((checks + 1)) + if [ "$2" != "$3" ]; then + echo " FAIL $1: want '$2', got '$3'" >&2 + fails=$((fails + 1)) + fi +} +fail() { fails=$((fails + 1)); echo " FAIL $1" >&2; } +has() { + checks=$((checks + 1)) + case "$2" in *"$3"*) ;; *) fail "$1" ;; esac +} + +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../cve-lib.sh +. "${SCRIPTS}/cve-lib.sh" + +# Mixed severities. The third Trivy target has no .Vulnerabilities key at all, +# which is the case that breaks a naive `add`. +cat > "${TMP}/trivy.json" <<'JSON' +{"Results":[ + {"Target":"a","Vulnerabilities":[ + {"Severity":"CRITICAL","VulnerabilityID":"CVE-C","PkgName":"openssl", + "InstalledVersion":"3.0","FixedVersion":"3.1","PrimaryURL":"https://x/CVE-C"}, + {"Severity":"HIGH","VulnerabilityID":"CVE-H1","PkgName":"libfoo", + "InstalledVersion":"1.0","FixedVersion":"1.1"}, + {"Severity":"LOW","VulnerabilityID":"CVE-L","PkgName":"zlib", + "InstalledVersion":"2.0","FixedVersion":"2.1"}]}, + {"Target":"b","Vulnerabilities":[ + {"Severity":"HIGH","VulnerabilityID":"CVE-H2","PkgName":"libbar", + "InstalledVersion":"3.0","FixedVersion":"3.1"}, + {"Severity":"MEDIUM","VulnerabilityID":"CVE-M","PkgName":"curl", + "InstalledVersion":"8.0","FixedVersion":"8.1"}]}, + {"Target":"c"} +]} +JSON +cat > "${TMP}/grype.json" <<'JSON' +{"matches":[ + {"vulnerability":{"severity":"Critical","id":"CVE-C","dataSource":"https://x/CVE-C", + "fix":{"versions":["1.1"]}},"artifact":{"name":"libfoo","version":"1.0"}}, + {"vulnerability":{"severity":"High","id":"CVE-H1","dataSource":"", + "fix":{"versions":[]}},"artifact":{"name":"libfoo","version":"1.0"}}, + {"vulnerability":{"severity":"High","id":"CVE-H2","dataSource":"", + "fix":{"versions":[]}},"artifact":{"name":"libbar","version":"2.0"}}, + {"vulnerability":{"severity":"Negligible","id":"CVE-N","dataSource":"", + "fix":{"versions":[]}},"artifact":{"name":"libbaz","version":"3.0"}} +]} +JSON + +# --- the normaliser --------------------------------------------------------- +# Both scanner shapes must collapse to one record, or every renderer downstream +# has to know which scanner it is looking at. +assert_eq "trivy normalises to the shared record" \ + "CRITICAL CVE-C openssl 3.0 3.1 https://x/CVE-C" \ + "$(cve_jq "${TMP}/trivy.json" trivy \ + '.[0] | "\(.sev) \(.id) \(.pkg) \(.installed) \(.fixed) \(.url)"')" +assert_eq "grype normalises to the shared record" \ + "CRITICAL CVE-C libfoo 1.0 1.1 https://x/CVE-C" \ + "$(cve_jq "${TMP}/grype.json" grype \ + '.[0] | "\(.sev) \(.id) \(.pkg) \(.installed) \(.fixed) \(.url)"')" +# Grype's fix.versions is a list; an empty one must read as "no fix", not "[]". +assert_eq "grype unfixed has an empty fix" "" \ + "$(cve_jq "${TMP}/grype.json" grype '.[1].fixed')" + +# The prelude is in scope for every program, so renderers share one vocabulary. +assert_eq "prelude exposes code spans" "${bt}x${bt}" \ + "$(cve_jq "${TMP}/grype.json" grype '"x" | code')" +assert_eq "prelude renders a blank as a dash" '—' \ + "$(cve_jq "${TMP}/grype.json" grype '"" | code')" +assert_eq "prelude ranks worst first" "0 1 2 3" \ + "$(cve_jq "${TMP}/grype.json" grype \ + '[("CRITICAL","HIGH","MEDIUM","LOW") | rank] | join(" ")')" + +checks=$((checks + 1)) +if cve_jq "${TMP}/grype.json" snyk '.' >/dev/null 2>&1; then + fail "an unknown scanner must be rejected, not silently normalised to nothing" +fi + +# --- counts ----------------------------------------------------------------- +cve_counts "${TMP}/trivy.json" "${TMP}/grype.json" +assert_eq "trivy critical" "1" "${CVE_TRIVY_CRITICAL}" +assert_eq "trivy high only" "2" "${CVE_TRIVY_HIGH_ONLY}" +assert_eq "trivy crit+high" "3" "${CVE_TRIVY_HIGH}" +assert_eq "trivy medium" "1" "${CVE_TRIVY_MEDIUM}" +assert_eq "trivy low" "1" "${CVE_TRIVY_LOW}" +assert_eq "trivy total" "5" "${CVE_TRIVY_TOTAL}" +assert_eq "grype critical" "1" "${CVE_GRYPE_CRITICAL}" +assert_eq "grype high only" "2" "${CVE_GRYPE_HIGH_ONLY}" +assert_eq "grype crit+high" "3" "${CVE_GRYPE_HIGH}" +assert_eq "grype medium" "0" "${CVE_GRYPE_MEDIUM}" +assert_eq "grype low" "0" "${CVE_GRYPE_LOW}" +# Negligible counts towards the total but towards no C/H/M/L column, so the +# total is deliberately not their sum. +assert_eq "grype total" "4" "${CVE_GRYPE_TOTAL}" + +assert_eq "compact form" "1C / 2H / 0M / 0L" \ + "$(cve_chml "${CVE_GRYPE_CRITICAL}" "${CVE_GRYPE_HIGH_ONLY}" \ + "${CVE_GRYPE_MEDIUM}" "${CVE_GRYPE_LOW}")" + +# Clean reports must be zero, not empty or an error. +echo '{"Results":[]}' > "${TMP}/t0.json" +echo '{"matches":[]}' > "${TMP}/g0.json" +cve_counts "${TMP}/t0.json" "${TMP}/g0.json" +for v in CVE_TRIVY_CRITICAL CVE_TRIVY_HIGH_ONLY CVE_TRIVY_HIGH \ + CVE_TRIVY_MEDIUM CVE_TRIVY_LOW CVE_TRIVY_TOTAL \ + CVE_GRYPE_CRITICAL CVE_GRYPE_HIGH_ONLY CVE_GRYPE_HIGH \ + CVE_GRYPE_MEDIUM CVE_GRYPE_LOW CVE_GRYPE_TOTAL; do + assert_eq "clean ${v}" "0" "${!v}" +done + +# Severity spelling must not matter: Trivy shouts, Grype title-cases, and a +# scanner changing its mind should not silently zero the badge. +echo '{"Results":[{"Vulnerabilities":[{"Severity":"critical"}]}]}' > "${TMP}/tlc.json" +echo '{"matches":[{"vulnerability":{"severity":"CRITICAL"}}]}' > "${TMP}/glc.json" +cve_counts "${TMP}/tlc.json" "${TMP}/glc.json" +assert_eq "trivy lowercase severity" "1" "${CVE_TRIVY_CRITICAL}" +assert_eq "grype uppercase severity" "1" "${CVE_GRYPE_CRITICAL}" + +# A missing report must fail loudly rather than count as clean. +checks=$((checks + 1)) +if cve_counts "${TMP}/nope.json" "${TMP}/g0.json" 2>/dev/null; then + fail "missing report should return non-zero" +fi + +# --- the shared summary section -------------------------------------------- +out="$(cve_summary_section "${TMP}/trivy.json" "${TMP}/grype.json" demo-image)" + +has "section names the image" "${out}" "${bt}demo-image${bt}" +has "section breaks out severity" "${out}" "| Scanner | Critical | High | Medium | Low | Total |" +# Trivy's row: 1 critical, 2 high, 1 medium, 1 low, 5 total — the badge numbers. +has "section carries badge counts" "${out}" ":red_circle: 1 | :orange_circle: 2 | 1 | 1 | 5 |" +# Grype finds no medium or low here, so those columns must read 0, not blank. +has "section shows empty severities" "${out}" ":orange_circle: 2 | 0 | 0 | 4 |" +# Top-offender table is what makes it actionable. +has "section lists top components" "${out}" "${bt}libfoo 1.0${bt}" + +clean="$(cve_summary_section "${TMP}/t0.json" "${TMP}/g0.json" clean-image)" +has "clean image shows a tick" "${clean}" ":white_check_mark:" +checks=$((checks + 1)) +case "${clean}" in *":red_circle:"*) fail "clean image must not show red" ;; *) ;; esac + +# cve-lib.sh is sourced, never executed: an exec bit on a file with no shebang +# trips the pre-commit hook, and a bulk chmod has already done it once. +checks=$((checks + 1)) +if [ -x "${SCRIPTS}/cve-lib.sh" ]; then + fail "cve-lib.sh is a sourced library and must not be executable" +fi + +if [ "${fails}" -ne 0 ]; then + echo "cve-lib: ${fails}/${checks} failed" >&2 + exit 1 +fi +echo "cve-lib: ${checks} checks passed" diff --git a/.github/scripts/tests/test-cve-publish.sh b/.github/scripts/tests/test-cve-publish.sh new file mode 100755 index 0000000..e22975d --- /dev/null +++ b/.github/scripts/tests/test-cve-publish.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# +# Fixture tests for scripts/cve-publish.sh — the badge JSON and the Markdown +# page it links to, for one image. Silent on success; prints only failures. +set -euo pipefail + +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${SCRIPTS}/cve-publish.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +# The expected strings are Markdown, so they are full of code spans. A pair of +# literal backticks in one string reads as command substitution (SC2016), hence +# the single one built up from here. +bt='`' + +checks=0 +fails=0 +fail() { fails=$((fails + 1)); echo " FAIL $1" >&2; } +assert_eq() { + checks=$((checks + 1)) + if [ "$2" != "$3" ]; then + echo " FAIL $1: want '$2', got '$3'" >&2 + fails=$((fails + 1)) + fi +} +has() { + checks=$((checks + 1)) + case "$2" in *"$3"*) ;; *) fail "$1" ;; esac +} +hasnt() { + checks=$((checks + 1)) + case "$2" in *"$3"*) fail "$1" ;; *) ;; esac +} +field() { jq -r "$2" "$1"; } + +# Mixed severities, including a Trivy target with no .Vulnerabilities key and a +# Grype negligible — neither belongs in a C/H/M/L column. +cat > "${TMP}/trivy.json" <<'JSON' +{"Results":[ + {"Target":"a","Vulnerabilities":[ + {"Severity":"LOW","VulnerabilityID":"CVE-2-LOW","PkgName":"zlib", + "InstalledVersion":"1.0","FixedVersion":"1.1", + "PrimaryURL":"https://avd.aquasec.com/CVE-2-LOW"}, + {"Severity":"CRITICAL","VulnerabilityID":"CVE-1-CRIT","PkgName":"openssl", + "InstalledVersion":"3.0","FixedVersion":"3.1"}]}, + {"Target":"b"} +]} +JSON +cat > "${TMP}/grype.json" <<'JSON' +{"matches":[ + {"vulnerability":{"severity":"Negligible","id":"CVE-9-NEG","dataSource":"", + "fix":{"versions":[],"state":"not-fixed"}}, + "artifact":{"name":"busybox","version":"1.36"}}, + {"vulnerability":{"severity":"High","id":"CVE-3-HIGH", + "dataSource":"https://nvd.nist.gov/CVE-3-HIGH","fix":{"versions":["2.0"]}}, + "artifact":{"name":"libfoo","version":"1.9"}} +]} +JSON + +"${SCRIPT}" "${TMP}/trivy.json" "${TMP}/grype.json" demo "${TMP}/out" > /dev/null + +# --- badges ----------------------------------------------------------------- +t="${TMP}/out/demo-trivy.json" +g="${TMP}/out/demo-grype.json" +assert_eq "trivy message" "1C / 0H / 0M / 1L" "$(field "${t}" .message)" +assert_eq "trivy color" "red" "$(field "${t}" .color)" +assert_eq "trivy label" "trivy" "$(field "${t}" .label)" +assert_eq "trivy logo" "aquasec" "$(field "${t}" .namedLogo)" +assert_eq "trivy schema" "1" "$(field "${t}" .schemaVersion)" +assert_eq "grype message" "0C / 1H / 0M / 0L" "$(field "${g}" .message)" +assert_eq "grype color" "orange" "$(field "${g}" .color)" +assert_eq "grype logo" "anchore" "$(field "${g}" .namedLogo)" + +# --- reports ---------------------------------------------------------------- +tmd="$(cat "${TMP}/out/demo-trivy.md")" +gmd="$(cat "${TMP}/out/demo-grype.md")" + +has "trivy report is titled per image and scanner" "${tmd}" "# ${bt}demo${bt} — Trivy CVE report" +has "grype report is titled per image and scanner" "${gmd}" "# ${bt}demo${bt} — Grype CVE report" + +# The header table is what the badge shows; the two must not drift. +has "trivy header counts" "${tmd}" '| 1 | 0 | 0 | 1 | 2 |' +has "grype header counts" "${gmd}" '| 0 | 1 | 0 | 0 | 2 |' + +has "trivy links a CVE with a URL" "${tmd}" "[${bt}CVE-2-LOW${bt}](https://avd.aquasec.com/CVE-2-LOW)" +has "trivy falls back to a bare ID" "${tmd}" "| ${bt}CVE-1-CRIT${bt} |" +has "trivy carries the fix version" "${tmd}" "${bt}3.0${bt} | ${bt}3.1${bt} |" +has "grype links a CVE" "${gmd}" "[${bt}CVE-3-HIGH${bt}](https://nvd.nist.gov/CVE-3-HIGH)" +has "grype marks an unfixed finding" "${gmd}" "${bt}1.36${bt} | — |" + +crit_line="$(grep -n 'CVE-1-CRIT' "${TMP}/out/demo-trivy.md" | cut -d: -f1)" +low_line="$(grep -n 'CVE-2-LOW' "${TMP}/out/demo-trivy.md" | cut -d: -f1)" +checks=$((checks + 1)) +[ "${crit_line}" -lt "${low_line}" ] || fail "findings must be sorted worst-first" + +# Every finding must appear, or the page contradicts the badge it is linked from. +assert_eq "trivy lists every finding" "2" \ + "$(grep -c '^| :' "${TMP}/out/demo-trivy.md")" +assert_eq "grype lists every finding, negligible included" "2" \ + "$(grep -c '^| :' "${TMP}/out/demo-grype.md")" + +# A timestamp or digest here would make publish-badges.sh commit every night +# even when no finding moved, destroying the "commit only if changed" signal. +"${SCRIPT}" "${TMP}/trivy.json" "${TMP}/grype.json" demo "${TMP}/out2" > /dev/null +for f in demo-trivy.md demo-grype.md demo-trivy.json demo-grype.json; do + checks=$((checks + 1)) + if ! diff -q "${TMP}/out/${f}" "${TMP}/out2/${f}" >/dev/null; then + fail "two runs on the same input must produce an identical ${f}" + fi +done + +# --- clean image ------------------------------------------------------------ +echo '{"Results":[]}' > "${TMP}/t0.json" +echo '{"matches":[]}' > "${TMP}/g0.json" +"${SCRIPT}" "${TMP}/t0.json" "${TMP}/g0.json" clean "${TMP}/out" > /dev/null +assert_eq "clean trivy message" "0C / 0H / 0M / 0L" "$(field "${TMP}/out/clean-trivy.json" .message)" +assert_eq "clean trivy color" "brightgreen" "$(field "${TMP}/out/clean-trivy.json" .color)" +assert_eq "clean grype color" "brightgreen" "$(field "${TMP}/out/clean-grype.json" .color)" +clean="$(cat "${TMP}/out/clean-trivy.md")" +has "clean report says so" "${clean}" 'No vulnerabilities reported.' +hasnt "clean report has no findings" "${clean}" '| Severity | ID |' + +# Low/medium only must stay green — the colour tracks critical+high even though +# the message now shows every severity. +echo '{"Results":[{"Vulnerabilities":[{"Severity":"LOW"},{"Severity":"MEDIUM"}]}]}' > "${TMP}/t1.json" +echo '{"matches":[{"vulnerability":{"severity":"Low"}}]}' > "${TMP}/g1.json" +"${SCRIPT}" "${TMP}/t1.json" "${TMP}/g1.json" lowonly "${TMP}/out" > /dev/null +assert_eq "low-only trivy message" "0C / 0H / 1M / 1L" "$(field "${TMP}/out/lowonly-trivy.json" .message)" +assert_eq "low-only trivy color" "brightgreen" "$(field "${TMP}/out/lowonly-trivy.json" .color)" + +# A missing report must fail, not publish a badge and page claiming zero CVEs. +checks=$((checks + 1)) +if "${SCRIPT}" "${TMP}/nope.json" "${TMP}/g0.json" x "${TMP}/out" >/dev/null 2>&1; then + fail "missing trivy report should exit non-zero" +fi + +if [ "${fails}" -ne 0 ]; then + echo "cve-publish: ${fails}/${checks} failed" >&2 + exit 1 +fi +echo "cve-publish: ${checks} checks passed" diff --git a/.github/scripts/tests/test-publish-badges.sh b/.github/scripts/tests/test-publish-badges.sh index cd77f41..b7daae3 100755 --- a/.github/scripts/tests/test-publish-badges.sh +++ b/.github/scripts/tests/test-publish-badges.sh @@ -60,10 +60,16 @@ echo '{"schemaVersion":1,"label":"trivy CVEs","message":"0 high / 1 total","colo > "${TMP}/src1/foo-trivy.json" echo '{"schemaVersion":1,"label":"grype CVEs","message":"0 high / 2 total","color":"brightgreen"}' \ > "${TMP}/src1/foo-grype.json" +# The Markdown reports the badges link to ride along in the same directory. +echo '# foo — Trivy CVE report' > "${TMP}/src1/foo-trivy.md" +echo '# foo — Grype CVE report' > "${TMP}/src1/foo-grype.md" "${SCRIPT}" "${TMP}/src1" badges > /dev/null 2>&1 -assert_eq "first run: files" "foo-grype.json foo-trivy.json" "$(inspect_ls)" +assert_eq "first run: files" \ + "foo-grype.json foo-grype.md foo-trivy.json foo-trivy.md" "$(inspect_ls)" +assert_eq "first run: report content" '# foo — Trivy CVE report' \ + "$(inspect_read foo-trivy.md)" assert_eq "first run: content" '{"schemaVersion":1,"label":"trivy CVEs","message":"0 high / 1 total","color":"brightgreen"}' \ "$(inspect_read foo-trivy.json)" assert_eq "first run: commits" "1" "$(inspect_commits)" @@ -79,19 +85,26 @@ echo '{"schemaVersion":1,"label":"trivy CVEs","message":"1 high / 3 total","colo # note: foo-grype.json intentionally omitted — must be removed from the branch echo '{"schemaVersion":1,"label":"trivy CVEs","message":"0 high / 0 total","color":"brightgreen"}' \ > "${TMP}/src2/bar-trivy.json" +# foo-*.md and foo-grype.json are gone: a stale report is worse than none, since +# the badge would still link to it. +echo '# bar — Trivy CVE report' > "${TMP}/src2/bar-trivy.md" "${SCRIPT}" "${TMP}/src2" badges > /dev/null 2>&1 -assert_eq "third run: files" "bar-trivy.json foo-trivy.json" "$(inspect_ls)" +assert_eq "third run: files" \ + "bar-trivy.json bar-trivy.md foo-trivy.json" "$(inspect_ls)" assert_eq "third run: updated content" '{"schemaVersion":1,"label":"trivy CVEs","message":"1 high / 3 total","color":"orange"}' \ "$(inspect_read foo-trivy.json)" assert_eq "third run: commits advance" "2" "$(inspect_commits)" # --- Refuse to blank the branch when src has no JSON ----------------------- +# Markdown alone is not enough: the badges are the thing the README cannot do +# without, so a run that produced only reports must not wipe them. mkdir -p "${TMP}/empty" +echo '# orphan report' > "${TMP}/empty/orphan-trivy.md" checks=$((checks + 1)) if "${SCRIPT}" "${TMP}/empty" badges >/dev/null 2>&1; then - echo " FAIL empty src-dir should exit non-zero" >&2 + echo " FAIL src-dir with no badge JSON should exit non-zero" >&2 fails=$((fails + 1)) fi assert_eq "empty run: branch untouched" "2" "$(inspect_commits)" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 320208d..870eca0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -454,13 +454,13 @@ jobs: path: images/${{ matrix.image.dir }}/image.tar retention-days: 7 - - name: Build CVE badge JSON + - name: Build CVE badge JSON and Markdown report if: inputs.publish env: IMAGE_NAME: ${{ matrix.image.name }} - run: ./.github/scripts/cve-badge.sh trivy.json grype.json "${IMAGE_NAME}" badges + run: ./.github/scripts/cve-publish.sh trivy.json grype.json "${IMAGE_NAME}" badges - - name: Upload badge JSON + - name: Upload badge JSON and report if: inputs.publish uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 619ad1d..6d5f299 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -4,7 +4,7 @@ name: nightly # upstream — which only holds while every run resolves against the live Wolfi # index, so nothing here may cache apks or pin versions: publish multi-arch, # sign + attest (SBOM and SLSA provenance), scan, then push refreshed CVE badge -# JSON to the `badges` branch. +# JSON and the Markdown reports those badges link to, to the `badges` branch. # # The build itself lives in build.yml, which pr.yml calls too — the only # difference here is `publish: true`. Do not fork a nightly-only copy of those @@ -58,7 +58,7 @@ jobs: with: persist-credentials: false - - name: Download all badge artifacts + - name: Download all badge and report artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: badges-* diff --git a/AGENTS.md b/AGENTS.md index d775eb0..56dbfbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,9 +167,11 @@ licence is missing, which is why this is a rule rather than a check. per-step `if:` guards to skip images (that once needed the same condition on 16 steps), and do not glob for melange configs inside a workflow `run:` step — `build-matrix.sh` owns both, so the two copies cannot drift. -- **If a CVE gate is ever added, gate on Grype**, never Trivy alone: Trivy - attributes melange-built files to an APK with no advisories and reports 0 - where `trivy rootfs` on the same binary reports findings. +- **Never let Trivy fall back to `detection-priority: precise`** (its default). + It then attributes every jar and Go binary to the melange-built APK that + installed it, finds no advisories for a package only we publish, and reports + 0 — a green badge over real CVEs, failing nothing. `scan-image` sets + `comprehensive`; `test-scan-image.sh` enforces it because nothing else would. - Keep the CVE comment out of the scan matrix (it would post once per image), and do not hand a marketplace action `pull-requests: write` on a public repo. diff --git a/README.md b/README.md index 09905e6..29f612a 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,32 @@ Docker Hub name. The base images are GHCR-only. Nightly scans of each `:latest` image, refreshed on every push. +Each badge reads `C / H / M / L` — findings at critical, high, +medium and low severity. **Click one for the full listing**: every finding with +its CVE ID, package, installed version and fix, refreshed by the same run. + | Image | Trivy | Grype | |-------|-------|-------| -| `base-os` | ![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-os-trivy.json) | ![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-os-grype.json) | -| `base-jre-25` | ![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-jre-25-trivy.json) | ![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-jre-25-grype.json) | -| `base-monitoring` | ![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-monitoring-trivy.json) | ![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-monitoring-grype.json) | -| `conduktor-debug` | ![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/conduktor-debug-trivy.json) | ![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/conduktor-debug-grype.json) | +| `base-os` | [![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-os-trivy.json)](../../blob/badges/base-os-trivy.md) | [![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-os-grype.json)](../../blob/badges/base-os-grype.md) | +| `base-jre-25` | [![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-jre-25-trivy.json)](../../blob/badges/base-jre-25-trivy.md) | [![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-jre-25-grype.json)](../../blob/badges/base-jre-25-grype.md) | +| `base-monitoring` | [![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-monitoring-trivy.json)](../../blob/badges/base-monitoring-trivy.md) | [![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/base-monitoring-grype.json)](../../blob/badges/base-monitoring-grype.md) | +| `conduktor-debug` | [![trivy](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/conduktor-debug-trivy.json)](../../blob/badges/conduktor-debug-trivy.md) | [![grype](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/conduktor/container-images/badges/conduktor-debug-grype.json)](../../blob/badges/conduktor-debug-grype.md) | + +Trivy runs with `--ignore-unfixed`, so it lists only what a rebuild can clear. +Grype includes unfixed findings and every severity down to negligible. The two +disagreeing is expected; that is why both are here. + +Pull requests get the same per-severity table as a comment, from the same +counting code, so a PR and a badge never report different numbers. The raw scan JSON and SBOMs are attached as workflow artifacts on each [nightly run](../../actions/workflows/nightly.yml) (30-day retention). @@ -245,9 +259,10 @@ accidentally-staged secrets. [packages.wolfi.dev](https://packages.wolfi.dev/os/) before adding. - The nightly workflow assumes GHCR pushes are enabled via the built-in `GITHUB_TOKEN` (`packages: write`). No extra secrets are required for - signing, provenance, or the CVE badges — the badges are plain JSON files - that the `publish-badges` job commits to the dedicated `badges` branch - after every nightly (main is protected). + signing, provenance, or the CVE badges — the badges are plain JSON files, + and the reports they link to plain Markdown, that the `publish-badges` job + commits to the dedicated `badges` branch after every nightly (main is + protected). - Every third-party GitHub Action is pinned to a commit SHA; Dependabot ([`.github/dependabot.yml`](.github/dependabot.yml)) opens PRs to bump them weekly.