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
9 changes: 6 additions & 3 deletions .github/actions/scan-image/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 ))
Expand Down
55 changes: 0 additions & 55 deletions .github/scripts/cve-badge.sh

This file was deleted.

49 changes: 0 additions & 49 deletions .github/scripts/cve-counts.sh

This file was deleted.

176 changes: 176 additions & 0 deletions .github/scripts/cve-lib.sh
Original file line number Diff line number Diff line change
@@ -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_<S>_CRITICAL critical only
# CVE_<S>_HIGH_ONLY high only
# CVE_<S>_HIGH critical + high — what `fail-on: high` gates on
# CVE_<S>_MEDIUM medium only
# CVE_<S>_LOW low only
# CVE_<S>_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 <report> <trivy|grype> <program>
#
# Run <program> 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<details><summary>Grype critical+high by component</summary>\n\n'
printf '| Component | Findings |\n|-----------|----------|\n%s\n' "${top}"
printf '\n</details>\n'
fi
}

# Printed once per summary, however many sections it holds.
cve_scanner_note() {
printf '<sub>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.</sub>\n'
}
Loading
Loading