From fcd74003a95e03244520ec422e9d11049e9e9fe0 Mon Sep 17 00:00:00 2001 From: Hallgeir Garnes-Gutvik Date: Fri, 3 Jul 2026 15:46:03 +0200 Subject: [PATCH 01/32] Extend Trivy scanning with action recommendations Run additional scan of latest base-image (if a newer one exists) to see if the vulnerability findings are fixed there. Use this reference to recommend mitigations - bump if patch exists and it fixes things, else do workaround or ignore --- .github/scripts/analyze-base-fixes.sh | 136 ++++++++++++++++++ .github/scripts/derive-base-image.sh | 82 +++++++++++ .../scripts/tests/fixtures/app-findings.json | 54 +++++++ .../tests/fixtures/base-latest-findings.json | 18 +++ .github/scripts/tests/run-tests.sh | 100 +++++++++++++ .github/workflows/container-scan.yml | 133 ++++++++++++++--- 6 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/analyze-base-fixes.sh create mode 100644 .github/scripts/derive-base-image.sh create mode 100644 .github/scripts/tests/fixtures/app-findings.json create mode 100644 .github/scripts/tests/fixtures/base-latest-findings.json create mode 100644 .github/scripts/tests/run-tests.sh diff --git a/.github/scripts/analyze-base-fixes.sh b/.github/scripts/analyze-base-fixes.sh new file mode 100644 index 00000000..33fef3b7 --- /dev/null +++ b/.github/scripts/analyze-base-fixes.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# +# Classify Trivy findings for the application image into actionable mitigations +# and render a markdown table to the GitHub Actions job summary. +# +# For every CRITICAL/HIGH finding it decides one of: +# - App dependency -> fix in the .csproj (not a base-image concern) +# - Base image bump -> a newer base image already ships the fix +# - Not yet fixed upstream -> present in the latest base too; needs a +# Dockerfile workaround or an upstream fix +# +# "Base-origin" (i.e. comes from the base image, not your app) is determined +# from the app scan alone: OS packages (Class == os-pkgs) and the bundled .NET +# runtime (Type == dotnet-core, or a package path under the shared framework). +# Whether a base-origin finding is already fixed is answered by diffing its +# vulnerability ID against a scan of the latest base image, which the caller +# supplies only when a newer base actually exists. +# +# Usage: analyze-base-fixes.sh [base-latest-trivy.json] +# +# Environment (all optional, used for wording only): +# HAS_NEW_BASE "true" when a newer base image was found and scanned +# FLOATING_TAG floating channel tag, e.g. 10.0-alpine3.23 +# LATEST_VERSION concrete latest patch, e.g. 10.0.11-alpine3.23 +# BASE_TAG currently pinned tag, e.g. 10.0.9-alpine3.23 +# +# Output goes to $GITHUB_STEP_SUMMARY when set, otherwise to stdout. + +set -euo pipefail + +app_json="${1:?usage: analyze-base-fixes.sh [base-latest-trivy.json]}" +base_json="${2:-}" + +if [ ! -f "$app_json" ]; then + echo "analyze-base-fixes.sh: file not found: $app_json" >&2 + exit 1 +fi + +has_new_base="${HAS_NEW_BASE:-false}" +floating_tag="${FLOATING_TAG:-the latest base image}" +latest_version="${LATEST_VERSION:-}" +base_tag="${BASE_TAG:-}" + +# Set of vulnerability IDs still present in the latest base image (if scanned). +declare -A latest_base_ids=() +if [ -n "$base_json" ] && [ -f "$base_json" ]; then + while IFS= read -r id; do + [ -n "$id" ] && latest_base_ids["$id"]=1 + done < <(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique[]' "$base_json" | tr -d '\r') +fi + +# Emit each finding as a tab-separated row from the app scan. +# Fields: id, pkg, installed, fixed, severity, class, type, pkgpath +extract() { + jq -r ' + .Results[]? as $r + | ($r.Class // "") as $class + | ($r.Type // "") as $type + | ($r.Target // "") as $target + | ($r.Vulnerabilities // [])[] + | [ .VulnerabilityID, + .PkgName, + (.InstalledVersion // ""), + (.FixedVersion // "-"), + (.Severity // ""), + $class, + $type, + (.PkgPath // $target) ] + | @tsv + ' "$app_json" +} + +is_base_origin() { + local class="$1" type="$2" path="$3" + [ "$class" = "os-pkgs" ] && return 0 + [ "$type" = "dotnet-core" ] && return 0 + case "$path" in + usr/share/dotnet/*|/usr/share/dotnet/*|usr/lib/dotnet/*|/usr/lib/dotnet/*) return 0 ;; + esac + return 1 +} + +rows="" +count_total=0 +count_bump=0 +count_upstream=0 +count_appdep=0 + +while IFS=$'\t' read -r id pkg installed fixed severity class type path; do + [ -z "$id" ] && continue + count_total=$((count_total + 1)) + + if is_base_origin "$class" "$type" "$path"; then + if [ "$has_new_base" = "true" ] && [ -n "${latest_base_ids[$id]:-}" ]; then + verdict="⏳ Not yet fixed upstream — Dockerfile workaround or wait" + count_upstream=$((count_upstream + 1)) + elif [ "$has_new_base" = "true" ]; then + target="${latest_version:-$floating_tag}" + verdict="✅ Base image bump — update Dockerfile to \`$target\`" + count_bump=$((count_bump + 1)) + else + verdict="⏳ Already on latest base — Dockerfile workaround or wait" + count_upstream=$((count_upstream + 1)) + fi + else + verdict="🔧 App dependency — update the package in its .csproj" + count_appdep=$((count_appdep + 1)) + fi + + rows+="| ${severity} | ${id} | \`${pkg}\` | ${installed} | ${fixed} | ${verdict} |"$'\n' +done < <(extract | tr -d '\r') + +# Render. +{ + echo "## 🐳 Base image mitigation analysis" + echo + if [ -n "$base_tag" ]; then + echo "Deployed base image: \`${base_tag}\`" + fi + if [ "$has_new_base" = "true" ]; then + echo "A newer base image is available: \`${latest_version:-$floating_tag}\`" + else + echo "No newer base image is published for \`${floating_tag}\` — you are already on the latest." + fi + echo + + if [ "$count_total" -eq 0 ]; then + echo "No CRITICAL/HIGH findings to analyze. ✅" + else + echo "**${count_total}** finding(s): **${count_bump}** fixable by a base image bump, **${count_upstream}** awaiting an upstream fix, **${count_appdep}** app dependencies." + echo + echo "| Severity | CVE | Package | Installed | Fixed in | Mitigation |" + echo "| --- | --- | --- | --- | --- | --- |" + printf '%s' "$rows" + fi +} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" diff --git a/.github/scripts/derive-base-image.sh b/.github/scripts/derive-base-image.sh new file mode 100644 index 00000000..cfc51e79 --- /dev/null +++ b/.github/scripts/derive-base-image.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Derive the deployed base image reference from a Dockerfile. +# +# The scanned artifact is the final build stage (docker build with no --target), +# so the *last* FROM line identifies the base image that actually ships. This +# script parses that line and derives the floating channel tag Microsoft +# publishes (major.minor + OS suffix), which always points at the newest patch. +# Nothing is hardcoded, so bumping the Dockerfile to a new .NET or OS version is +# picked up automatically. +# +# Usage: derive-base-image.sh +# Output: KEY=VALUE lines (suitable for appending to $GITHUB_OUTPUT). +# +# BASE_REPO e.g. mcr.microsoft.com/dotnet/aspnet +# BASE_TAG e.g. 10.0.9-alpine3.23 +# BASE_DIGEST e.g. sha256:... (empty if the FROM line is not pinned) +# BASE_VERSION e.g. 10.0.9 +# BASE_CHANNEL e.g. 10.0 +# OS_SUFFIX e.g. alpine3.23 (empty if the tag has no OS suffix) +# FLOATING_TAG e.g. 10.0-alpine3.23 + +set -euo pipefail + +dockerfile="${1:?usage: derive-base-image.sh }" + +if [ ! -f "$dockerfile" ]; then + echo "derive-base-image.sh: file not found: $dockerfile" >&2 + exit 1 +fi + +# Last FROM line = the final stage = the image that gets tagged and scanned. +from_line=$(grep -iE '^[[:space:]]*FROM[[:space:]]' "$dockerfile" | tail -n1) +if [ -z "$from_line" ]; then + echo "derive-base-image.sh: no FROM line found in $dockerfile" >&2 + exit 1 +fi + +# Strip the leading "FROM" keyword and any trailing "AS " alias, then the +# only remaining token is the image reference (repo:tag@digest). +ref=$(printf '%s\n' "$from_line" \ + | sed -E 's/^[[:space:]]*[Ff][Rr][Oo][Mm][[:space:]]+//; s/[[:space:]]+[Aa][Ss][[:space:]]+.*$//' \ + | tr -d '[:space:]') + +# Split off the optional @sha256:... digest. +digest="" +case "$ref" in + *@*) digest="${ref#*@}" ;; +esac +image_and_tag="${ref%@*}" + +# repo is everything before the last ':', tag is everything after it. +repo="${image_and_tag%:*}" +tag="${image_and_tag##*:}" +if [ "$repo" = "$image_and_tag" ]; then + # No ':' present -> untagged reference; treat the whole thing as the repo. + repo="$image_and_tag" + tag="" +fi + +# Derive the floating channel tag: reduce the version to major.minor and keep +# the OS suffix verbatim. 10.0.9-alpine3.23 -> 10.0-alpine3.23 +version="${tag%%-*}" +os_suffix="" +case "$tag" in + *-*) os_suffix="${tag#*-}" ;; +esac +channel=$(printf '%s\n' "$version" | awk -F. '{ if (NF>=2) print $1"."$2; else print $1 }') + +if [ -n "$os_suffix" ]; then + floating="${channel}-${os_suffix}" +else + floating="${channel}" +fi + +printf 'BASE_REPO=%s\n' "$repo" +printf 'BASE_TAG=%s\n' "$tag" +printf 'BASE_DIGEST=%s\n' "$digest" +printf 'BASE_VERSION=%s\n' "$version" +printf 'BASE_CHANNEL=%s\n' "$channel" +printf 'OS_SUFFIX=%s\n' "$os_suffix" +printf 'FLOATING_TAG=%s\n' "$floating" diff --git a/.github/scripts/tests/fixtures/app-findings.json b/.github/scripts/tests/fixtures/app-findings.json new file mode 100644 index 00000000..f1cc3930 --- /dev/null +++ b/.github/scripts/tests/fixtures/app-findings.json @@ -0,0 +1,54 @@ +{ + "Results": [ + { + "Target": "altinn-profile:abc123 (alpine 3.23.0)", + "Class": "os-pkgs", + "Type": "alpine", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-OS-FIXED", + "PkgName": "musl", + "InstalledVersion": "1.2.5-r0", + "FixedVersion": "1.2.5-r1", + "Severity": "HIGH" + }, + { + "VulnerabilityID": "CVE-OS-STILL", + "PkgName": "openssl", + "InstalledVersion": "3.5.0-r0", + "FixedVersion": "3.5.1-r0", + "Severity": "CRITICAL" + } + ] + }, + { + "Target": "usr/share/dotnet/shared/Microsoft.AspNetCore.App/10.0.9/System.Text.Json.dll", + "Class": "lang-pkgs", + "Type": "dotnet-core", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-RUNTIME-FIXED", + "PkgName": "System.Text.Json", + "InstalledVersion": "10.0.9", + "FixedVersion": "10.0.11", + "Severity": "HIGH" + } + ] + }, + { + "Target": "app/Altinn.Profile.deps.json", + "Class": "lang-pkgs", + "Type": "nuget", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-APP-DEP", + "PkgName": "Some.Vulnerable.Package", + "InstalledVersion": "1.0.0", + "FixedVersion": "1.0.2", + "Severity": "HIGH", + "PkgPath": "app/Some.Vulnerable.Package.dll" + } + ] + } + ] +} diff --git a/.github/scripts/tests/fixtures/base-latest-findings.json b/.github/scripts/tests/fixtures/base-latest-findings.json new file mode 100644 index 00000000..d9edf890 --- /dev/null +++ b/.github/scripts/tests/fixtures/base-latest-findings.json @@ -0,0 +1,18 @@ +{ + "Results": [ + { + "Target": "mcr.microsoft.com/dotnet/aspnet:10.0-alpine3.23 (alpine 3.23.0)", + "Class": "os-pkgs", + "Type": "alpine", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-OS-STILL", + "PkgName": "openssl", + "InstalledVersion": "3.5.0-r0", + "FixedVersion": "3.5.1-r0", + "Severity": "CRITICAL" + } + ] + } + ] +} diff --git a/.github/scripts/tests/run-tests.sh b/.github/scripts/tests/run-tests.sh new file mode 100644 index 00000000..2606463a --- /dev/null +++ b/.github/scripts/tests/run-tests.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Fixture-based tests for the container-scan helper scripts. No Docker or +# network access required — pure data in, verdicts out. +# +# Usage: .github/scripts/tests/run-tests.sh + +set -uo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +scripts="$(cd "$here/.." && pwd)" +fixtures="$here/fixtures" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +pass=0 +fail=0 + +ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; pass=$((pass + 1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail + 1)); } + +# assert_kv