Skip to content
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8e8062c
feat(UP-0): report full-image CVEs in PR comment, not just the delta
amitaboudi Jun 7, 2026
b86d06c
fix(UP-0): keep multi-arch comment under size/arg limits
amitaboudi Jun 7, 2026
242486c
refactor(UP-0): measure tables before appending; safer truncation
amitaboudi Jun 8, 2026
eb1b14a
feat(UP-0): add main_branch input to diff image scans vs base branch
amitaboudi Jun 11, 2026
238f27e
fix: don't use ${{ }} expression syntax in input description
amitaboudi Jun 11, 2026
cac8988
test: combine full-image renderer (#37) + vs-main wiring (#38)
amitaboudi Jun 11, 2026
11785d8
test: use vsmain-test dev binary (combined #37+#38, do not merge)
amitaboudi Jun 11, 2026
5cca4a3
test: diff-focused comment (introduced/resolved tables + total count,…
amitaboudi Jun 11, 2026
d8e1d9e
test: comment table -> CVE(link)/Package/Version/Fix; drop URL+timing
amitaboudi Jun 11, 2026
3cd74cc
feat(UP-0): diff-vs-main PR comment (introduced/resolved tables + tot…
amitaboudi Jun 11, 2026
709ff66
feat(UP-0): add Upwind Console link + scan duration footer (SCA-style)
amitaboudi Jun 11, 2026
2542ce5
test: restore vsmain-test dev binary URL (demo only)
amitaboudi Jun 11, 2026
da29649
fix(UP-0): console link -> image Shift-left panel keyed by imageVersion
amitaboudi Jun 11, 2026
18db9c3
fix(UP-0): console link -> /code SCA scan-at-build panel keyed by fin…
amitaboudi Jun 11, 2026
4babaee
feat(UP-0): show per-arch header + scan Status above the diff summary
amitaboudi Jun 11, 2026
cc72e2c
test: point binary channel at up2562-test (UP-2562 binary-twin FP dev…
nofar-shapir Jul 7, 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
280 changes: 180 additions & 100 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 @@ -140,7 +153,7 @@ runs:
else
RELEASE_BUCKET="releases.upwind.io"
fi
UPWIND_AGENT_URL="https://$RELEASE_BUCKET/$UPWIND_AGENT/stable/$OS/$ARCH/$UPWIND_AGENT-$OS-$ARCH"
UPWIND_AGENT_URL="https://$RELEASE_BUCKET/$UPWIND_AGENT/up2562-test/$OS/$ARCH/$UPWIND_AGENT-$OS-$ARCH"

echo "Downloading from $UPWIND_AGENT_URL"
curl -fsS -H "Authorization: Bearer $TOKEN" -L "$UPWIND_AGENT_URL" -o "$AGENT_OUTPUT"
Expand All @@ -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"