Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fcd7400
Extend Trivy scanning with action recommendations
hggutvik Jul 3, 2026
51b4764
Enforce https in curl to microsoft for image tag
hggutvik Jul 6, 2026
8a3c89c
Use safer [[ rather than [
hggutvik Jul 6, 2026
fd04dee
Make scanning behavior (vuln-types) explicit
hggutvik Jul 6, 2026
a008d08
[[ ]] on the remaining conditions. Add default case *)
hggutvik Jul 8, 2026
7f36e74
Don't persist GH creds in job env
hggutvik Jul 8, 2026
6ad1a75
Add comment about Trivy version used
hggutvik Jul 9, 2026
faaf66c
Deliberately downgrade to vulnerable version to test the new workflow…
hggutvik Jul 9, 2026
7113795
Fix benign git call failed warning in SARIF upload
hggutvik Jul 9, 2026
110c893
Include digest in mitigation analysis.
hggutvik Jul 9, 2026
7651080
Test if ignore still works
hggutvik Jul 9, 2026
e851d71
Render a note on making an exploitability verdict
hggutvik Jul 9, 2026
15dfb4b
Comment back out after veryfing it still works
hggutvik Jul 9, 2026
f9ca7ca
Fix Markdown rendering of newly added echos
hggutvik Jul 9, 2026
3fe13b5
Skip echoing actual count of findings
hggutvik Jul 9, 2026
39c04c6
Bump base image back to clean version
hggutvik Jul 9, 2026
582a134
Elaborate on trivy ignoring
hggutvik Jul 9, 2026
0a11033
Deliberately add a vulnerable package to test Trivy
hggutvik Jul 9, 2026
94a690f
Replace vulnerable package with another that doesn't fail build
hggutvik Jul 9, 2026
7098dd0
Try yet another shabby 3rd party package
hggutvik Jul 9, 2026
2d6970f
Debug the details for app dep findings
hggutvik Jul 9, 2026
f6b5dd2
Temp disable irrelevant workflows to not occupy more runners than needed
hggutvik Jul 9, 2026
e52b345
Fix discrimination of app-deps
hggutvik Jul 9, 2026
34f72e9
Revert temp/debug changes
hggutvik Jul 9, 2026
0745335
Use double squares
hggutvik Jul 9, 2026
0c4719f
Add explicit returns and local vars for positional params in test hel…
hggutvik Jul 9, 2026
5828b9f
Don't give false verdicts for faulty JSON
hggutvik Jul 10, 2026
d8bf5a5
Make image ref extract more robust.
hggutvik Jul 10, 2026
b9786b8
Fix latent bug by adding back newline.
hggutvik Jul 10, 2026
3c2bd24
Add a regression test for handling future flags after FROM
hggutvik Jul 10, 2026
52be62a
Merge branch 'main' into devsecops/recommend-actions-on-trivy-findings
hggutvik Aug 18, 2026
0a6bbd5
Test scan with a known vulnerable package in a project
hggutvik Aug 18, 2026
bb47521
Remove temp package added for testing purposes
hggutvik Aug 18, 2026
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
188 changes: 188 additions & 0 deletions .github/scripts/analyze-base-fixes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/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 .NET assemblies
# under the shared framework path (usr/share/dotnet/...). The app's own NuGet
# packages -- whose deps.json Target is under app/ -- are app dependencies.
# (Trivy labels both the runtime and the app's deps.json as Type dotnet-core,
# so the location, not the type, is what discriminates them.)
# 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 <app-trivy.json> [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
# LATEST_DIGEST digest the floating tag currently resolves to, e.g. sha256:...
# BASE_TAG currently pinned tag, e.g. 10.0.9-alpine3.23
# BASE_DIGEST digest currently pinned in the Dockerfile, e.g. sha256:...
#
# The report shows the full tag@digest for the deployed and available images
# (so the digest can be copied straight into the Dockerfile) because a rebuilt
# base is often published under an unchanged version tag -- the digest is then
# the only thing that changed. Table cells use the short (12-char) digest to
# stay narrow.
#
# Output goes to $GITHUB_STEP_SUMMARY when set, otherwise to stdout.

set -euo pipefail

app_json="${1:?usage: analyze-base-fixes.sh <app-trivy.json> [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:-}"
base_digest="${BASE_DIGEST:-}"
latest_digest="${LATEST_DIGEST:-}"

# Join a tag/version with its digest for display: "tag@sha256:..." (or just
# "tag" when the digest is unknown). The digest is the only thing that
# distinguishes a rebuilt image published under an unchanged version tag.
ref_with_digest() {
local ref="$1" digest="$2"
if [[ -n "$digest" ]]; then
printf '%s@%s' "$ref" "$digest"
else
printf '%s' "$ref"
fi
}

# Abbreviate a digest to its first 12 hex chars (docker-style), e.g.
# sha256:f03685b2735e... -> sha256:f03685b2735e. Used in the table so cells
# stay narrow; the header keeps the full, copy-pasteable digest.
short_digest() {
local digest="$1"
if [[ "$digest" == sha256:* ]]; then
local hex="${digest#sha256:}"
printf 'sha256:%s' "${hex:0:12}"
else
printf '%s' "$digest"
fi
}

# 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
if ! base_ids="$(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique[]' "$base_json")"; then
echo "analyze-base-fixes.sh: invalid JSON in $base_json" >&2
exit 1
fi
while IFS= read -r id; do
[[ -n "$id" ]] && latest_base_ids["$id"]=1
done < <(printf '%s\n' "$base_ids" | tr -d '\r')
elif [[ "$has_new_base" = "true" ]]; then
echo "analyze-base-fixes.sh: HAS_NEW_BASE=true but no base scan file provided" >&2
exit 1
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Emit each finding as a tab-separated row from the app scan.
# Fields: id, pkg, installed, fixed, severity, class, location
# `location` is the package path when Trivy provides one, otherwise the result
# Target. For .deps.json findings PkgPath is null, so the Target is what tells
# the runtime shared framework apart from the app's own packages.
extract() {
jq -r '
.Results[]? as $r
| ($r.Class // "") as $class
| ($r.Target // "") as $target
| ($r.Vulnerabilities // [])[]
| [ .VulnerabilityID,
.PkgName,
(.InstalledVersion // ""),
(.FixedVersion // "-"),
(.Severity // ""),
$class,
(.PkgPath // $target) ]
| @tsv
' "$app_json"
}

is_base_origin() {
local class="$1" location="$2"
[[ "$class" = "os-pkgs" ]] && return 0
case "$location" in
usr/share/dotnet/*|/usr/share/dotnet/*|usr/lib/dotnet/*|/usr/lib/dotnet/*) return 0 ;;
*) return 1 ;; # app-level dependency, e.g. app/Altinn.Profile.deps.json
esac
}

rows=""
count_total=0
count_bump=0
count_upstream=0
count_appdep=0

while IFS=$'\t' read -r id pkg installed fixed severity class location; do
[[ -z "$id" ]] && continue
count_total=$((count_total + 1))

if is_base_origin "$class" "$location"; 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="$(ref_with_digest "${latest_version:-$floating_tag}" "$(short_digest "$latest_digest")")"
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: \`$(ref_with_digest "$base_tag" "$base_digest")\`"
fi
if [[ "$has_new_base" = "true" ]]; then
echo "A newer base image is available: \`$(ref_with_digest "${latest_version:-$floating_tag}" "$latest_digest")\`"
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"
echo
echo "**Always consider the exploitability of the findings:**"
echo
echo "- For exploitable vulnerabilities, patch and release ASAP."
echo "- For non-exploitable vulnerabilities:"
echo " - When there is an upstream fix for the base image or an app dependency, merge the patch to main and let the release follow the normal cadence cycle."
echo " - When awaiting the upstream fix, silence the finding by adding the CVE to \`.trivyignore.yaml\`. Include a reason that summarizes why it's not exploitable, add a reasonable expiry time, and merge to main."
fi
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
94 changes: 94 additions & 0 deletions .github/scripts/derive-base-image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/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 <Dockerfile>
# 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
#
# BASE_DIGEST is both compared against the floating tag's live digest (to decide
# whether a newer base exists) and shown in the mitigation report, so a rebuild
# published under an unchanged version tag is still detected and surfaced.

set -euo pipefail

dockerfile="${1:?usage: derive-base-image.sh <Dockerfile>}"

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

# Extract the image reference (repo:tag@digest) from the FROM line. A FROM line
# may carry flags, e.g. `FROM --platform=$BUILDPLATFORM image AS build`, so we
# tokenise: skip the FROM keyword and any --flag tokens, then take the first
# remaining token -- the image reference. The optional `AS <stage>` alias comes
# after the image, so it is never reached.
ref=$(printf '%s\n' "$from_line" | awk '{
for (i = 1; i <= NF; i++) {
if (toupper($i) == "FROM") continue # the FROM keyword
if ($i ~ /^--/) continue # a flag, e.g. --platform=linux/amd64
print $i # first non-flag token = image reference
exit
}
}')

# Split off the optional @sha256:... digest.
case "$ref" in
*@*) digest="${ref#*@}" ;;
*) digest="" ;; # reference is not pinned by digest
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%%-*}"
case "$tag" in
*-*) os_suffix="${tag#*-}" ;; # e.g. 10.0.9-alpine3.23 -> alpine3.23
*) os_suffix="" ;; # tag has no OS suffix, e.g. 10.0.9
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"
53 changes: 53 additions & 0 deletions .github/scripts/tests/fixtures/app-findings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"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/Microsoft.AspNetCore.App.deps.json",
"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": "dotnet-core",
"Vulnerabilities": [
{
"VulnerabilityID": "CVE-APP-DEP",
"PkgName": "SixLabors.ImageSharp",
"InstalledVersion": "2.1.0",
"FixedVersion": "2.1.10, 3.1.7",
"Severity": "HIGH"
}
]
}
]
}
18 changes: 18 additions & 0 deletions .github/scripts/tests/fixtures/base-latest-findings.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
Loading
Loading