Skip to content
Open
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
278 changes: 179 additions & 99 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ inputs:
description: Enable debug logging
default: false
type: boolean
main_branch:
description: >-
Base/target branch to diff against (e.g. the PR's base branch, typically
github.event.pull_request.base.ref). When set, introduced/resolved CVEs
are computed vs the latest scanned image of this branch instead of the
previously scanned image. Requires that branch to have been scanned.
required: false
pr_id:
description: Pull request identifier associated with the scan (optional)
required: false
pr_link:
description: Pull request URL associated with the scan (optional)
required: false
block_on:
description: Block workflow based on Upwind Scan Recommendation. Can be either 'do_not_deploy' or 'deploy_with_caution'

Expand Down Expand Up @@ -151,6 +164,7 @@ runs:
OUTPUT_JSON: ${{ inputs.output_json }}
run: |
echo "Running Upwind Scan"
SCAN_START=$(date +%s)
COMMIT_SHA=${GITHUB_SHA}
if [[ -n "${{ inputs.commit_sha }}" ]]; then
COMMIT_SHA=${{ inputs.commit_sha }}
Expand All @@ -159,7 +173,21 @@ runs:
if [ "${{ inputs.use_sudo }}" = "true" ]; then
SUDO=sudo
fi


# Optional base-branch diff args. Only added when provided, so the
# command stays compatible with shiftleft binaries that predate these
# flags (they are passed only when the user opts in via main_branch).
EXTRA_ARGS=()
if [ -n "${{ inputs.main_branch }}" ]; then
EXTRA_ARGS+=(--main-branch="${{ inputs.main_branch }}")
fi
if [ -n "${{ inputs.pr_id }}" ]; then
EXTRA_ARGS+=(--pr-id="${{ inputs.pr_id }}")
fi
if [ -n "${{ inputs.pr_link }}" ]; then
EXTRA_ARGS+=(--pr-link="${{ inputs.pr_link }}")
fi

$SUDO ./shiftleft image \
--source=GITHUB_ACTIONS \
--initiator=${GITHUB_TRIGGERING_ACTOR} \
Expand All @@ -178,13 +206,23 @@ runs:
--output-json=$OUTPUT_JSON \
--oci-client=${{ inputs.oci_client }} \
--block-on="${{ inputs.block_on}}" \
--should-perform-multi-platform-scan=${{ inputs.perform_multiarchitecture_image_scan}}
--should-perform-multi-platform-scan=${{ inputs.perform_multiarchitecture_image_scan}} \
"${EXTRA_ARGS[@]}"
if [ ! -f "$OUTPUT_JSON" ]; then
echo "Error: $OUTPUT_JSON not found"
exit 1
fi

echo "Info: Image scan completed"

# Human-readable scan duration for the PR comment footer.
SCAN_ELAPSED=$(( $(date +%s) - SCAN_START ))
if [ "$SCAN_ELAPSED" -ge 60 ]; then
SCAN_DURATION_HUMAN="$((SCAN_ELAPSED / 60))m $((SCAN_ELAPSED % 60))s"
else
SCAN_DURATION_HUMAN="${SCAN_ELAPSED}s"
fi
echo "SCAN_DURATION_HUMAN=${SCAN_DURATION_HUMAN}" >> "$GITHUB_ENV"
- name: Comment on PR (conditional)
shell: bash
env:
Expand All @@ -193,6 +231,8 @@ runs:
PR_NUMBER: ${{ inputs.pr_number }}
ADD_COMMENT: ${{ inputs.add_comment }}
OUTPUT_JSON: ${{ inputs.output_json }}
MAIN_BRANCH: ${{ inputs.main_branch }}
UPWIND_URI: ${{ inputs.upwind_uri }}
run: |
if [ "$ADD_COMMENT" != "true" ]; then
echo "Info: Skipping comment"
Expand All @@ -204,130 +244,170 @@ runs:
exit 1
fi

# Normalise JSON
# Normalise the scan output (stream of objects) into a JSON array.
ARRAY="$(mktemp)"

# Create an array from stream of valid json objects
jq -cs 'if type=="array" then . else [.] end' "$OUTPUT_JSON" > "$ARRAY"

CLEAN_ARCHES=""
# If the backend wasn't ready and returned no usable data, skip the
# comment rather than posting an "unknown:unknown" placeholder.
if [ "$(jq '[.[] | select((.imageName // "") != "")] | length' "$ARRAY")" -eq 0 ]; then
echo "Warning: scan results not ready (empty output); skipping comment"
exit 0
fi

# Build base comment
IMAGE_NAME=$(jq -r '.[0].imageName // "unknown"' "$ARRAY")
IMAGE_VERSION=$(jq -r '.[0].imageVersion // "unknown"' "$ARRAY")
FINGERPRINT=$(jq -r '.[0].fingerprint // ""' "$ARRAY")
ARCH_COUNT=$(jq 'length' "$ARRAY")

# Label for what the diff is computed against.
if [ -n "$MAIN_BRANCH" ]; then
BASE_LABEL="\`${MAIN_BRANCH#refs/heads/}\`"
else
BASE_LABEL="the previous scan"
fi

MAX_ROWS=60

COMMENT="# 🏄‍♂️ Upwind Image Scan Report"$'\n'
COMMENT+="**Image:** \`$IMAGE_NAME:$IMAGE_VERSION\`"$'\n\n'

# Loop through each architecture
while IFS= read -r scan; do
ARCH=$(echo "$scan" | jq -r '.arch? // ""')
[ -z "$ARCH" ] && ARCH="arch not specified"
STATUS=$(echo "$scan" | jq -r '.scanStatus // "unknown"')

T=$(echo "$scan" | jq '.introducedCves // [] | length')
N=$(echo "$scan" | jq '[.introducedCves // [] | .[] | select(.status=="introduced")] | length')
O=$(echo "$scan" | jq '[.introducedCves // [] | .[] | select(.status=="no_change")] | length')
H=$(echo "$scan" | jq '[.introducedCves // [] | .[] | select(.severity=="HIGH")] | length')
C=$(echo "$scan" | jq '[.introducedCves // [] | .[] | select(.severity=="CRITICAL")] | length')
OTHER=$(echo "$scan" | jq '[.introducedCves // [] | .[] | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))] | length')

if [ "$T" -eq 0 ]; then
CLEAN_ARCHES+="- \`$ARCH\` (status: \`$STATUS\`)"$'\n'
continue
# Render a collapsible table of the given CVE JSON array (capped).
# $1=cves json $2=summary line $3=with_fix ("1" to include a Fix column)
emit_table() {
local cves_json="$1" summary="$2" with_fix="$3" rows total
rows=$(echo "$cves_json" | jq -r --argjson max "$MAX_ROWS" '
[.[]][0:$max][]
| [ (.cveId // .cveName // "<unknown>"),
(.packageName // ""),
(.packageVersion // ""),
(.fixedInVersion // "") ]
| @tsv')
total=$(echo "$cves_json" | jq 'length')
COMMENT+="<details><summary>${summary}</summary>"$'\n\n'
if [ "$with_fix" = "1" ]; then
COMMENT+=$'| CVE | Package | Version | Fix |\n|---|---|---|---|\n'
else
COMMENT+=$'| CVE | Package | Version |\n|---|---|---|\n'
fi

# Header for this arch
COMMENT+="## $ARCH"$'\n'
COMMENT+="- **Status:** \`$STATUS\`"$'\n'
COMMENT+="- **Total CVEs:** \`$T\` (\`$N\` new, \`$O\` existing)"$'\n'
COMMENT+="- **Critical:** \`$C\`, **High:** \`$H\`"$'\n\n'

build_table() {
local sev="$1" heading="$2" rows
if [ "$sev" = "OTHER" ]; then
# Everything not CRITICAL/HIGH (MEDIUM/LOW/UNKNOWN)
rows=$(echo "$scan" | jq -r '
[.introducedCves // [] | .[]
| select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))][]
| [
(.cveId // .cveName // "<unknown>"),
((.cveDescription // "")
| gsub("\\|"; "\\\\|")
| gsub("`"; "\\\\`")
| gsub("<"; "&lt;")
| gsub(">"; "&gt;")
| gsub("\r?\n"; " ")
| .[0:400]),
((.packageName // "<pkg>") + " " + (.packageVersion // ""))
]
| @tsv
')
while IFS=$'\t' read -r CVE PKG VER FIX; do
[ -z "${CVE}" ] && CVE="<unknown>"
# Link CVE ids to their NVD detail page.
case "$CVE" in
CVE-*) CVE_CELL="[${CVE}](https://nvd.nist.gov/vuln/detail/${CVE})" ;;
*) CVE_CELL="${CVE}" ;;
esac
local pkg_cell="—" ver_cell="—" fix_cell="—"
[ -n "$PKG" ] && pkg_cell="\`${PKG}\`"
[ -n "$VER" ] && ver_cell="\`${VER}\`"
[ -n "$FIX" ] && fix_cell="\`${FIX}\`"
if [ "$with_fix" = "1" ]; then
COMMENT+="| ${CVE_CELL} | ${pkg_cell} | ${ver_cell} | ${fix_cell} |"$'\n'
else
rows=$(echo "$scan" | jq -r --arg sev "$sev" '
[.introducedCves // [] | .[] | select((.severity // "" | ascii_upcase)==$sev)][]
| [
(.cveId // .cveName // "<unknown>"),
((.cveDescription // "")
| gsub("\\|"; "\\\\|")
| gsub("`"; "\\\\`")
| gsub("<"; "&lt;")
| gsub(">"; "&gt;")
| gsub("\r?\n"; " ")
| .[0:400]),
((.packageName // "<pkg>") + " " + (.packageVersion // ""))
]
| @tsv
')
COMMENT+="| ${CVE_CELL} | ${pkg_cell} | ${ver_cell} |"$'\n'
fi
[ -z "$rows" ] && return 0

COMMENT+=$'| CVE | Description | Package |\n'
COMMENT+=$'|---|---|---|\n'
while IFS=$'\t' read -r CVE DESC PKG; do
[ -z "${CVE}" ] && CVE="<unknown>"
[ -z "${PKG}" ] && PKG="<pkg>"
COMMENT+="| ${CVE} | ${DESC} | ${PKG} |"$'\n'
done <<< "$rows"
COMMENT+=$'\n'
}

# CRITICAL table (visible if any)
if [ "$C" -gt 0 ]; then
COMMENT+=$'### Critical severity\n\n'
build_table "CRITICAL" "Critical severity"
done <<< "$rows"
if [ "$total" -gt "$MAX_ROWS" ]; then
COMMENT+="_…and $((total - MAX_ROWS)) more._"$'\n'
fi
COMMENT+=$'</details>\n\n'
}

# Introduced findings for one severity, as a collapsible section.
emit_introduced_sev() {
local intro_json="$1" sev="$2" emoji="$3" label="$4" n sfx
n=$(echo "$intro_json" | jq --arg s "$sev" '[.[] | select((.severity // "" | ascii_upcase)==$s)] | length')
[ "$n" -eq 0 ] && return 0
sfx="s"; if [ "$n" -eq 1 ]; then sfx=""; fi
emit_table "$(echo "$intro_json" | jq -c --arg s "$sev" '[.[] | select((.severity // "" | ascii_upcase)==$s)]')" \
"${emoji} <strong>${label}</strong> · ${n} finding${sfx}" "1"
}

while IFS= read -r scan; do
ARCH=$(echo "$scan" | jq -r '.arch? // ""')
STATUS=$(echo "$scan" | jq -r '.scanStatus // ""')
INTRO=$(echo "$scan" | jq -c '.introducedCves // []')
RESOLVED=$(echo "$scan" | jq -c '.resolvedCves // []')
PRESENT=$(echo "$scan" | jq -c '(.introducedCves // []) + (.noChangeCves // [])')

# HIGH table (visible if any)
if [ "$H" -gt 0 ]; then
COMMENT+=$'### High severity\n\n'
build_table "HIGH" "High severity"
N=$(echo "$INTRO" | jq 'length')
R=$(echo "$RESOLVED" | jq 'length')
T=$(echo "$PRESENT" | jq 'length')
cC=$(echo "$PRESENT" | jq '[.[] | select((.severity // "" | ascii_upcase)=="CRITICAL")] | length')
cH=$(echo "$PRESENT" | jq '[.[] | select((.severity // "" | ascii_upcase)=="HIGH")] | length')
cM=$(echo "$PRESENT" | jq '[.[] | select((.severity // "" | ascii_upcase)=="MEDIUM")] | length')
cL=$(echo "$PRESENT" | jq '[.[] | select((.severity // "" | ascii_upcase)=="LOW")] | length')
cU=$(echo "$PRESENT" | jq '[.[] | select((.severity // "" | ascii_upcase) as $s | ($s!="CRITICAL" and $s!="HIGH" and $s!="MEDIUM" and $s!="LOW"))] | length')

# Per-arch header + status.
if [ -n "$ARCH" ]; then
COMMENT+="## $ARCH"$'\n'
fi
if [ -n "$STATUS" ]; then
COMMENT+="- **Status:** \`$STATUS\`"$'\n'
fi
if [ -n "$ARCH" ] || [ -n "$STATUS" ]; then
COMMENT+=$'\n'
fi

# Summary line: counts only (introduced / resolved / total present).
ivword="vulnerabilities"; if [ "$N" -eq 1 ]; then ivword="vulnerability"; fi
COMMENT+="**${N}** newly introduced ${ivword} · **${R}** resolved · **${T}** total in this PR vs ${BASE_LABEL}"$'\n'

# OTHER severities in a collapsed details section
if [ "$OTHER" -gt 0 ]; then
COMMENT+=$'<details><summary><strong>Other severities</strong> (Medium/Low/Unknown) — '
COMMENT+="\`$OTHER\` CVEs"
COMMENT+=$'</summary>'$'\n\n'
build_table "OTHER" "Other severities"
COMMENT+=$'</details>'$'\n\n'
# Severity breakdown of the total present (counts only, not listed).
BREAK=""
[ "$cC" -gt 0 ] && BREAK+="🔴 ${cC} Critical | "
[ "$cH" -gt 0 ] && BREAK+="🔶 ${cH} High | "
[ "$cM" -gt 0 ] && BREAK+="🟡 ${cM} Medium | "
[ "$cL" -gt 0 ] && BREAK+="🟢 ${cL} Low | "
[ "$cU" -gt 0 ] && BREAK+="⚪ ${cU} Other | "
BREAK="${BREAK% | }"
if [ -n "$BREAK" ]; then COMMENT+="$BREAK"$'\n'; fi
COMMENT+=$'\n'

# Detail tables: ONLY introduced (by severity) + resolved.
# The "total" / already-present CVEs are counted above but not listed.
emit_introduced_sev "$INTRO" "CRITICAL" "🔴" "Critical"
emit_introduced_sev "$INTRO" "HIGH" "🔶" "High"
emit_introduced_sev "$INTRO" "MEDIUM" "🟡" "Medium"
emit_introduced_sev "$INTRO" "LOW" "🟢" "Low"
if [ "$R" -gt 0 ]; then
rsfx="s"; if [ "$R" -eq 1 ]; then rsfx=""; fi
emit_table "$RESOLVED" "✅ <strong>Resolved</strong> · ${R} finding${rsfx}" "0"
fi
done < <(jq -c '.[]' "$ARRAY")

# Collapsed list of totally clean arches (if any)
if [ -n "$CLEAN_ARCHES" ]; then
COMMENT+=$'<details><summary><strong>More architectures</strong> — all clear ✅</summary>'$'\n\n'
COMMENT+="$CLEAN_ARCHES"$'\n'
COMMENT+=$'</details>'$'\n'
# Footer: link to the full analysis in the Upwind Console + scan time.
if [ -n "$FINGERPRINT" ]; then
COMMENT+="[View full analysis in Upwind Console →](https://console.${UPWIND_URI:-upwind.io}/code?mainPageTab=Reviews&secondaryTab=SCA&sidePanel=scan-at-build&sidePanelItemId=${FINGERPRINT})"$'\n\n'
fi
if [ -n "${SCAN_DURATION_HUMAN:-}" ]; then
COMMENT+="_Scan completed in ${SCAN_DURATION_HUMAN}_"$'\n'
fi

# Final hard safety net: never exceed GitHub's 65536-char limit.
if [ "${#COMMENT}" -gt 65000 ]; then
COMMENT="${COMMENT:0:64500}"
COMMENT="${COMMENT%$'\n'*}"
OPEN_TAGS=$(grep -c '<details>' <<< "$COMMENT" || true)
CLOSE_TAGS=$(grep -c '</details>' <<< "$COMMENT" || true)
while [ "$OPEN_TAGS" -gt "$CLOSE_TAGS" ]; do
COMMENT+=$'\n</details>'
CLOSE_TAGS=$((CLOSE_TAGS + 1))
done
COMMENT+=$'\n\n_Comment truncated to fit GitHub\'s size limit — see the Upwind Console._'
fi

echo "Posting summary comment on PR"

COMMENT_JSON=$(jq -n --arg body "$COMMENT" '{ body: $body }')
# Send the body via a file, never as a shell argument.
PAYLOAD_FILE="$(mktemp)"
printf '%s' "$COMMENT" | jq -Rs '{ body: . }' > "$PAYLOAD_FILE"
curl -L \
-X POST \
-H "Authorization: bearer $GH_TOKEN" \
-H "Content-Type: application/json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-d "$COMMENT_JSON" \
--data @"$PAYLOAD_FILE" \
"https://api.github.com/repos/$REPO/issues/$PR_NUMBER/comments"
rm -f "$PAYLOAD_FILE"