From 8e8062c19985ad923e1049177a7f67dc1e681fda Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Sun, 7 Jun 2026 17:52:34 +0300 Subject: [PATCH 01/13] feat(UP-0): report full-image CVEs in PR comment, not just the delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR comment was built only from `introducedCves` (CVEs newly introduced vs the previously-scanned image). On a re-scan where no NEW CVEs appear, the comment collapsed to "all clear ✅" even though the image still contained hundreds of CVEs — making a vulnerable PR look clean. The scan JSON already carries the full picture (`introducedCves`, `noChangeCves`, `resolvedCves`). Build the comment from the whole image (`introducedCves + noChangeCves`) and report absolute totals: Total CVEs in image: N (X new since last scan · Y already present · Z resolved) "all clear ✅" now means the image truly has 0 CVEs. Severity tables are capped at MAX_ROWS (80) per table with a "see Upwind Console" note so vulnerability-heavy images stay under GitHub's 65536-char comment limit. Co-Authored-By: Claude Opus 4.8 (1M context) --- action.yml | 56 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/action.yml b/action.yml index 498a28f..c176b53 100644 --- a/action.yml +++ b/action.yml @@ -219,18 +219,29 @@ runs: COMMENT="# 🏄‍♂️ Upwind Image Scan Report"$'\n' COMMENT+="**Image:** \`$IMAGE_NAME:$IMAGE_VERSION\`"$'\n\n' + # Max CVE rows rendered per severity table (keeps the comment under + # GitHub's 65536-char limit on vulnerability-heavy images). Remaining + # CVEs are summarised with a "see Upwind Console" note. + MAX_ROWS=80 + # 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') + # Full set of CVEs present in the image for this branch = + # newly introduced + already-present (no_change). This is the whole + # image, NOT just the diff vs the previously scanned image. + CVES=$(echo "$scan" | jq -c '(.introducedCves // []) + (.noChangeCves // [])') + + T=$(echo "$CVES" | jq 'length') + N=$(echo "$scan" | jq '(.introducedCves // []) | length') + O=$(echo "$scan" | jq '(.noChangeCves // []) | length') + R=$(echo "$scan" | jq '(.resolvedCves // []) | length') + H=$(echo "$CVES" | jq '[.[] | select((.severity // "" | ascii_upcase)=="HIGH")] | length') + C=$(echo "$CVES" | jq '[.[] | select((.severity // "" | ascii_upcase)=="CRITICAL")] | length') + OTHER=$(echo "$CVES" | jq '[.[] | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))] | length') if [ "$T" -eq 0 ]; then CLEAN_ARCHES+="- \`$ARCH\` (status: \`$STATUS\`)"$'\n' @@ -240,16 +251,17 @@ runs: # 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' + COMMENT+="- **Total CVEs in image:** \`$T\` (\`$N\` new since last scan · \`$O\` already present · \`$R\` resolved)"$'\n' + COMMENT+="- **Critical:** \`$C\`, **High:** \`$H\`, **Medium/Low/Other:** \`$OTHER\`"$'\n\n' build_table() { - local sev="$1" heading="$2" rows + local sev="$1" total rows if [ "$sev" = "OTHER" ]; then + total="$OTHER" # Everything not CRITICAL/HIGH (MEDIUM/LOW/UNKNOWN) - rows=$(echo "$scan" | jq -r ' - [.introducedCves // [] | .[] - | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))][] + rows=$(echo "$CVES" | jq -r --argjson max "$MAX_ROWS" ' + [.[] + | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))][0:$max][] | [ (.cveId // .cveName // ""), ((.cveDescription // "") @@ -258,14 +270,15 @@ runs: | gsub("<"; "<") | gsub(">"; ">") | gsub("\r?\n"; " ") - | .[0:400]), + | .[0:240]), ((.packageName // "") + " " + (.packageVersion // "")) ] | @tsv ') else - rows=$(echo "$scan" | jq -r --arg sev "$sev" ' - [.introducedCves // [] | .[] | select((.severity // "" | ascii_upcase)==$sev)][] + total=$(echo "$CVES" | jq --arg sev "$sev" '[.[] | select((.severity // "" | ascii_upcase)==$sev)] | length') + rows=$(echo "$CVES" | jq -r --arg sev "$sev" --argjson max "$MAX_ROWS" ' + [.[] | select((.severity // "" | ascii_upcase)==$sev)][0:$max][] | [ (.cveId // .cveName // ""), ((.cveDescription // "") @@ -274,14 +287,14 @@ runs: | gsub("<"; "<") | gsub(">"; ">") | gsub("\r?\n"; " ") - | .[0:400]), + | .[0:240]), ((.packageName // "") + " " + (.packageVersion // "")) ] | @tsv ') fi [ -z "$rows" ] && return 0 - + COMMENT+=$'| CVE | Description | Package |\n' COMMENT+=$'|---|---|---|\n' while IFS=$'\t' read -r CVE DESC PKG; do @@ -289,19 +302,22 @@ runs: [ -z "${PKG}" ] && PKG="" COMMENT+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' done <<< "$rows" + if [ "$total" -gt "$MAX_ROWS" ]; then + COMMENT+="_…and $((total - MAX_ROWS)) more — see the full list in the Upwind Console._"$'\n' + fi COMMENT+=$'\n' } # CRITICAL table (visible if any) if [ "$C" -gt 0 ]; then COMMENT+=$'### Critical severity\n\n' - build_table "CRITICAL" "Critical severity" + build_table "CRITICAL" fi # HIGH table (visible if any) if [ "$H" -gt 0 ]; then COMMENT+=$'### High severity\n\n' - build_table "HIGH" "High severity" + build_table "HIGH" fi # OTHER severities in a collapsed details section @@ -309,7 +325,7 @@ runs: COMMENT+=$'
Other severities (Medium/Low/Unknown) — ' COMMENT+="\`$OTHER\` CVEs" COMMENT+=$''$'\n\n' - build_table "OTHER" "Other severities" + build_table "OTHER" COMMENT+=$'
'$'\n\n' fi done < <(jq -c '.[]' "$ARRAY") From b86d06ceebdb9200c962463e58685dc581cf8177 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Sun, 7 Jun 2026 18:01:37 +0300 Subject: [PATCH 02/13] fix(UP-0): keep multi-arch comment under size/arg limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-arch, CVE-heavy images (e.g. ubuntu:22.04 → 7 platforms) made the comment large enough that `jq -n --arg body "$COMMENT"` exceeded the OS single-argument limit ("Argument list too long", exit 126), and could also blow past GitHub's 65536-char comment limit. - Send the body via a file (`jq -Rs` + `curl --data @file`), never as a shell argument. - Add a COMMENT_BUDGET so detail tables stop once near the limit while every per-arch summary (the whole-image totals) is always kept; note truncation once. Lower MAX_ROWS to 60 and add a final hard 65000-char safety net. Co-Authored-By: Claude Opus 4.8 (1M context) --- action.yml | 162 ++++++++++++++++++++++++++--------------------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/action.yml b/action.yml index c176b53..ac418d6 100644 --- a/action.yml +++ b/action.yml @@ -219,10 +219,72 @@ runs: COMMENT="# 🏄‍♂️ Upwind Image Scan Report"$'\n' COMMENT+="**Image:** \`$IMAGE_NAME:$IMAGE_VERSION\`"$'\n\n' - # Max CVE rows rendered per severity table (keeps the comment under - # GitHub's 65536-char limit on vulnerability-heavy images). Remaining - # CVEs are summarised with a "see Upwind Console" note. - MAX_ROWS=80 + # Caps that keep the comment under GitHub's 65536-char limit even for + # multi-arch, vulnerability-heavy images. MAX_ROWS bounds each severity + # table; COMMENT_BUDGET stops adding detail tables once we're close to + # the limit (per-arch summaries are always kept). TRUNCATED ensures the + # "truncated" note is added at most once. + MAX_ROWS=60 + COMMENT_BUDGET=44000 + TRUNCATED=0 + + # Append one severity table (heading + rows) for the current arch's + # $CVES to $COMMENT, honouring the size budget. $1 = CRITICAL|HIGH|OTHER. + build_table() { + local sev="$1" total rows heading collapse=0 + case "$sev" in + CRITICAL) heading=$'### Critical severity'; total="$C" ;; + HIGH) heading=$'### High severity'; total="$H" ;; + OTHER) collapse=1; total="$OTHER" ;; + esac + [ "${total:-0}" -eq 0 ] && return 0 + + # Size guard: keep the per-arch summaries, drop detail tables once we + # are near the limit, and say so once. + if [ "${#COMMENT}" -gt "$COMMENT_BUDGET" ]; then + if [ "$TRUNCATED" -ne 1 ]; then + COMMENT+=$'_Detailed CVE tables were truncated to stay within GitHub\'s comment size limit — see the full list in the Upwind Console._\n\n' + TRUNCATED=1 + fi + return 0 + fi + + if [ "$collapse" -eq 1 ]; then + # Everything not CRITICAL/HIGH (MEDIUM/LOW/UNKNOWN) + rows=$(echo "$CVES" | jq -r --argjson max "$MAX_ROWS" ' + [.[] | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))][0:$max][] + | [ (.cveId // .cveName // ""), + ((.cveDescription // "") | gsub("\\|";"\\\\|") | gsub("`";"\\\\`") | gsub("<";"<") | gsub(">";">") | gsub("\r?\n";" ") | .[0:240]), + ((.packageName // "") + " " + (.packageVersion // "")) ] + | @tsv') + else + rows=$(echo "$CVES" | jq -r --arg sev "$sev" --argjson max "$MAX_ROWS" ' + [.[] | select((.severity // "" | ascii_upcase)==$sev)][0:$max][] + | [ (.cveId // .cveName // ""), + ((.cveDescription // "") | gsub("\\|";"\\\\|") | gsub("`";"\\\\`") | gsub("<";"<") | gsub(">";">") | gsub("\r?\n";" ") | .[0:240]), + ((.packageName // "") + " " + (.packageVersion // "")) ] + | @tsv') + fi + [ -z "$rows" ] && return 0 + + if [ "$collapse" -eq 1 ]; then + COMMENT+="
Other severities (Medium/Low/Unknown) — \`$OTHER\` CVEs"$'\n\n' + else + COMMENT+="$heading"$'\n\n' + fi + COMMENT+=$'| CVE | Description | Package |\n' + COMMENT+=$'|---|---|---|\n' + while IFS=$'\t' read -r CVE DESC PKG; do + [ -z "${CVE}" ] && CVE="" + [ -z "${PKG}" ] && PKG="" + COMMENT+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' + done <<< "$rows" + if [ "$total" -gt "$MAX_ROWS" ]; then + COMMENT+="_…and $((total - MAX_ROWS)) more — see the full list in the Upwind Console._"$'\n' + fi + [ "$collapse" -eq 1 ] && COMMENT+=$'
\n' + COMMENT+=$'\n' + } # Loop through each architecture while IFS= read -r scan; do @@ -248,86 +310,15 @@ runs: continue fi - # Header for this arch + # Per-arch summary (always shown — this is the whole-image picture) COMMENT+="## $ARCH"$'\n' COMMENT+="- **Status:** \`$STATUS\`"$'\n' COMMENT+="- **Total CVEs in image:** \`$T\` (\`$N\` new since last scan · \`$O\` already present · \`$R\` resolved)"$'\n' COMMENT+="- **Critical:** \`$C\`, **High:** \`$H\`, **Medium/Low/Other:** \`$OTHER\`"$'\n\n' - build_table() { - local sev="$1" total rows - if [ "$sev" = "OTHER" ]; then - total="$OTHER" - # Everything not CRITICAL/HIGH (MEDIUM/LOW/UNKNOWN) - rows=$(echo "$CVES" | jq -r --argjson max "$MAX_ROWS" ' - [.[] - | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))][0:$max][] - | [ - (.cveId // .cveName // ""), - ((.cveDescription // "") - | gsub("\\|"; "\\\\|") - | gsub("`"; "\\\\`") - | gsub("<"; "<") - | gsub(">"; ">") - | gsub("\r?\n"; " ") - | .[0:240]), - ((.packageName // "") + " " + (.packageVersion // "")) - ] - | @tsv - ') - else - total=$(echo "$CVES" | jq --arg sev "$sev" '[.[] | select((.severity // "" | ascii_upcase)==$sev)] | length') - rows=$(echo "$CVES" | jq -r --arg sev "$sev" --argjson max "$MAX_ROWS" ' - [.[] | select((.severity // "" | ascii_upcase)==$sev)][0:$max][] - | [ - (.cveId // .cveName // ""), - ((.cveDescription // "") - | gsub("\\|"; "\\\\|") - | gsub("`"; "\\\\`") - | gsub("<"; "<") - | gsub(">"; ">") - | gsub("\r?\n"; " ") - | .[0:240]), - ((.packageName // "") + " " + (.packageVersion // "")) - ] - | @tsv - ') - fi - [ -z "$rows" ] && return 0 - - COMMENT+=$'| CVE | Description | Package |\n' - COMMENT+=$'|---|---|---|\n' - while IFS=$'\t' read -r CVE DESC PKG; do - [ -z "${CVE}" ] && CVE="" - [ -z "${PKG}" ] && PKG="" - COMMENT+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' - done <<< "$rows" - if [ "$total" -gt "$MAX_ROWS" ]; then - COMMENT+="_…and $((total - MAX_ROWS)) more — see the full list in the Upwind Console._"$'\n' - fi - COMMENT+=$'\n' - } - - # CRITICAL table (visible if any) - if [ "$C" -gt 0 ]; then - COMMENT+=$'### Critical severity\n\n' - build_table "CRITICAL" - fi - - # HIGH table (visible if any) - if [ "$H" -gt 0 ]; then - COMMENT+=$'### High severity\n\n' - build_table "HIGH" - fi - - # OTHER severities in a collapsed details section - if [ "$OTHER" -gt 0 ]; then - COMMENT+=$'
Other severities (Medium/Low/Unknown) — ' - COMMENT+="\`$OTHER\` CVEs" - COMMENT+=$''$'\n\n' - build_table "OTHER" - COMMENT+=$'
'$'\n\n' - fi + build_table "CRITICAL" + build_table "HIGH" + build_table "OTHER" done < <(jq -c '.[]' "$ARRAY") # Collapsed list of totally clean arches (if any) @@ -337,13 +328,22 @@ runs: COMMENT+=$''$'\n' fi + # Final hard safety net: never exceed GitHub's 65536-char comment limit. + if [ "${#COMMENT}" -gt 65000 ]; then + COMMENT="${COMMENT:0:64500}"$'\n\n_Comment truncated to fit GitHub\'s size limit — see the full results in the Upwind Console._' + fi + echo "Posting summary comment on PR" - COMMENT_JSON=$(jq -n --arg body "$COMMENT" '{ body: $body }') + # Send the (potentially large) body via a file, never as a shell + # argument — a big --arg/-d would hit the OS arg-length limit + # ("Argument list too long") on multi-arch, CVE-heavy images. + 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" From 242486c14c23311c6dff710c55bf909a60f50edb Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Mon, 8 Jun 2026 17:27:10 +0300 Subject: [PATCH 03/13] refactor(UP-0): measure tables before appending; safer truncation Build each severity table into a local buffer and append only if it fits COMMENT_BUDGET (raised 44000->60000 now that the check is exact), so more CVEs are shown while staying under GitHub's 65536-char comment limit. The final hard cap now truncates at a newline boundary and closes any unclosed
tags so the comment always renders valid markdown. Clean up the payload temp file. Co-Authored-By: Claude Opus 4.8 (1M context) --- action.yml | 58 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/action.yml b/action.yml index ac418d6..ae12bbe 100644 --- a/action.yml +++ b/action.yml @@ -225,13 +225,13 @@ runs: # the limit (per-arch summaries are always kept). TRUNCATED ensures the # "truncated" note is added at most once. MAX_ROWS=60 - COMMENT_BUDGET=44000 + COMMENT_BUDGET=60000 TRUNCATED=0 # Append one severity table (heading + rows) for the current arch's # $CVES to $COMMENT, honouring the size budget. $1 = CRITICAL|HIGH|OTHER. build_table() { - local sev="$1" total rows heading collapse=0 + local sev="$1" total rows heading collapse=0 buf="" case "$sev" in CRITICAL) heading=$'### Critical severity'; total="$C" ;; HIGH) heading=$'### High severity'; total="$H" ;; @@ -239,18 +239,7 @@ runs: esac [ "${total:-0}" -eq 0 ] && return 0 - # Size guard: keep the per-arch summaries, drop detail tables once we - # are near the limit, and say so once. - if [ "${#COMMENT}" -gt "$COMMENT_BUDGET" ]; then - if [ "$TRUNCATED" -ne 1 ]; then - COMMENT+=$'_Detailed CVE tables were truncated to stay within GitHub\'s comment size limit — see the full list in the Upwind Console._\n\n' - TRUNCATED=1 - fi - return 0 - fi - if [ "$collapse" -eq 1 ]; then - # Everything not CRITICAL/HIGH (MEDIUM/LOW/UNKNOWN) rows=$(echo "$CVES" | jq -r --argjson max "$MAX_ROWS" ' [.[] | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))][0:$max][] | [ (.cveId // .cveName // ""), @@ -267,23 +256,35 @@ runs: fi [ -z "$rows" ] && return 0 + # Build into a local buffer so we can measure before appending. if [ "$collapse" -eq 1 ]; then - COMMENT+="
Other severities (Medium/Low/Unknown) — \`$OTHER\` CVEs"$'\n\n' + buf+="
Other severities (Medium/Low/Unknown) — \`$OTHER\` CVEs"$'\n\n' else - COMMENT+="$heading"$'\n\n' + buf+="$heading"$'\n\n' fi - COMMENT+=$'| CVE | Description | Package |\n' - COMMENT+=$'|---|---|---|\n' + buf+=$'| CVE | Description | Package |\n' + buf+=$'|---|---|---|\n' while IFS=$'\t' read -r CVE DESC PKG; do [ -z "${CVE}" ] && CVE="" [ -z "${PKG}" ] && PKG="" - COMMENT+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' + buf+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' done <<< "$rows" if [ "$total" -gt "$MAX_ROWS" ]; then - COMMENT+="_…and $((total - MAX_ROWS)) more — see the full list in the Upwind Console._"$'\n' + buf+="_…and $((total - MAX_ROWS)) more — see the full list in the Upwind Console._"$'\n' fi - [ "$collapse" -eq 1 ] && COMMENT+=$'
\n' - COMMENT+=$'\n' + [ "$collapse" -eq 1 ] && buf+=$'
\n' + buf+=$'\n' + + # Size guard: only append this table if it fits within the budget. + if [ $((${#COMMENT} + ${#buf})) -gt "$COMMENT_BUDGET" ]; then + if [ "$TRUNCATED" -ne 1 ]; then + COMMENT+=$'_Detailed CVE tables were truncated to stay within GitHub\'s comment size limit — see the full list in the Upwind Console._\n\n' + TRUNCATED=1 + fi + return 0 + fi + + COMMENT+="$buf" } # Loop through each architecture @@ -328,9 +329,19 @@ runs: COMMENT+=$'
'$'\n' fi - # Final hard safety net: never exceed GitHub's 65536-char comment limit. + # Final hard safety net: never exceed GitHub's 65536-char limit. + # Truncate at a newline boundary and close any unclosed
tags + # so the PR comment renders valid markdown. if [ "${#COMMENT}" -gt 65000 ]; then - COMMENT="${COMMENT:0:64500}"$'\n\n_Comment truncated to fit GitHub\'s size limit — see the full results in the Upwind Console._' + COMMENT="${COMMENT:0:64500}" + COMMENT="${COMMENT%$'\n'*}" + OPEN_TAGS=$(grep -c '
' <<< "$COMMENT" || true) + CLOSE_TAGS=$(grep -c '
' <<< "$COMMENT" || true) + while [ "$OPEN_TAGS" -gt "$CLOSE_TAGS" ]; do + COMMENT+=$'\n
' + CLOSE_TAGS=$((CLOSE_TAGS + 1)) + done + COMMENT+=$'\n\n_Comment truncated to fit GitHub\'s size limit — see the full results in the Upwind Console._' fi echo "Posting summary comment on PR" @@ -347,3 +358,4 @@ runs: -H "X-GitHub-Api-Version: 2022-11-28" \ --data @"$PAYLOAD_FILE" \ "https://api.github.com/repos/$REPO/issues/$PR_NUMBER/comments" + rm -f "$PAYLOAD_FILE" From eb1b14a6ce609919763b9ad2e5013dfcfbeedd0e Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 10:14:57 +0300 Subject: [PATCH 04/13] feat(UP-0): add main_branch input to diff image scans vs base branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional `main_branch` (+ `pr_id`/`pr_link`) inputs. When `main_branch` is set, pass `--main-branch` (etc.) to the shiftleft binary so introduced/ resolved CVEs are computed against the latest scanned image of the base branch instead of the previous commit's image. Flags are appended only when the inputs are non-empty (via EXTRA_ARGS), so the action stays compatible with shiftleft binaries that predate the flags — default behaviour is unchanged. Depends on upwindsecurity/shiftleft#243 being merged and a new binary released before `main_branch` is actually used. Also requires the base branch to have been scanned (e.g. an on:push:[main] workflow) so a baseline exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- action.yml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 498a28f..a805ca8 100644 --- a/action.yml +++ b/action.yml @@ -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, such as + ${{ 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' @@ -159,7 +172,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} \ @@ -178,7 +205,8 @@ 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 From 238f27e5fb2cca80c50f36ea53a32d6a29b9a6d8 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 10:31:17 +0300 Subject: [PATCH 05/13] fix: don't use ${{ }} expression syntax in input description --- action.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index a805ca8..001f53d 100644 --- a/action.yml +++ b/action.yml @@ -70,10 +70,10 @@ inputs: type: boolean main_branch: description: >- - Base/target branch to diff against (e.g. the PR's base branch, such as - ${{ 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. + 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) From 11785d88407adfe6678bf6266228e6468112465e Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 11:33:28 +0300 Subject: [PATCH 06/13] test: use vsmain-test dev binary (combined #37+#38, do not merge) --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index a4fdf8e..7f4909f 100644 --- a/action.yml +++ b/action.yml @@ -153,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/vsmain-test/$OS/$ARCH/$UPWIND_AGENT-$OS-$ARCH" # TEST ONLY: dev binary built from shiftleft#243 echo "Downloading from $UPWIND_AGENT_URL" curl -fsS -H "Authorization: Bearer $TOKEN" -L "$UPWIND_AGENT_URL" -o "$AGENT_OUTPUT" From 5cca4a3693269fee63b19272548d37d77f508ad8 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 12:01:21 +0300 Subject: [PATCH 07/13] test: diff-focused comment (introduced/resolved tables + total count, vs main) --- action.yml | 208 ++++++++++++++++++++++++++--------------------------- 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/action.yml b/action.yml index 7f4909f..ea29b0c 100644 --- a/action.yml +++ b/action.yml @@ -164,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 }} @@ -213,6 +214,15 @@ runs: fi echo "Info: Image scan completed" + + # Record human-readable scan duration for the PR comment. + 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: @@ -221,6 +231,8 @@ runs: PR_NUMBER: ${{ inputs.pr_number }} ADD_COMMENT: ${{ inputs.add_comment }} OUTPUT_JSON: ${{ inputs.output_json }} + UPWIND_URI: ${{ inputs.upwind_uri }} + MAIN_BRANCH: ${{ inputs.main_branch }} run: | if [ "$ADD_COMMENT" != "true" ]; then echo "Info: Skipping comment" @@ -232,134 +244,124 @@ 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") - COMMENT="# 🏄‍♂️ Upwind Image Scan Report"$'\n' - COMMENT+="**Image:** \`$IMAGE_NAME:$IMAGE_VERSION\`"$'\n\n' + # 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 - # Caps that keep the comment under GitHub's 65536-char limit even for - # multi-arch, vulnerability-heavy images. MAX_ROWS bounds each severity - # table; COMMENT_BUDGET stops adding detail tables once we're close to - # the limit (per-arch summaries are always kept). TRUNCATED ensures the - # "truncated" note is added at most once. MAX_ROWS=60 - COMMENT_BUDGET=60000 - TRUNCATED=0 - - # Append one severity table (heading + rows) for the current arch's - # $CVES to $COMMENT, honouring the size budget. $1 = CRITICAL|HIGH|OTHER. - build_table() { - local sev="$1" total rows heading collapse=0 buf="" - case "$sev" in - CRITICAL) heading=$'### Critical severity'; total="$C" ;; - HIGH) heading=$'### High severity'; total="$H" ;; - OTHER) collapse=1; total="$OTHER" ;; - esac - [ "${total:-0}" -eq 0 ] && return 0 - - if [ "$collapse" -eq 1 ]; then - rows=$(echo "$CVES" | jq -r --argjson max "$MAX_ROWS" ' - [.[] | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))][0:$max][] - | [ (.cveId // .cveName // ""), - ((.cveDescription // "") | gsub("\\|";"\\\\|") | gsub("`";"\\\\`") | gsub("<";"<") | gsub(">";">") | gsub("\r?\n";" ") | .[0:240]), - ((.packageName // "") + " " + (.packageVersion // "")) ] - | @tsv') - else - rows=$(echo "$CVES" | jq -r --arg sev "$sev" --argjson max "$MAX_ROWS" ' - [.[] | select((.severity // "" | ascii_upcase)==$sev)][0:$max][] - | [ (.cveId // .cveName // ""), - ((.cveDescription // "") | gsub("\\|";"\\\\|") | gsub("`";"\\\\`") | gsub("<";"<") | gsub(">";">") | gsub("\r?\n";" ") | .[0:240]), - ((.packageName // "") + " " + (.packageVersion // "")) ] - | @tsv') - fi - [ -z "$rows" ] && return 0 - # Build into a local buffer so we can measure before appending. - if [ "$collapse" -eq 1 ]; then - buf+="
Other severities (Medium/Low/Unknown) — \`$OTHER\` CVEs"$'\n\n' - else - buf+="$heading"$'\n\n' - fi - buf+=$'| CVE | Description | Package |\n' - buf+=$'|---|---|---|\n' + COMMENT="# 🏄‍♂️ Upwind Image Scan Report"$'\n' + COMMENT+="**Image:** \`$IMAGE_NAME:$IMAGE_VERSION\`"$'\n\n' + + # Render a collapsible table of the given CVE JSON array (capped). + # $1=cves json $2=summary line (already includes emoji/label/count) + emit_table() { + local cves_json="$1" summary="$2" rows + rows=$(echo "$cves_json" | jq -r --argjson max "$MAX_ROWS" ' + [.[]][0:$max][] + | [ (.cveId // .cveName // ""), + ((.cveDescription // "") | gsub("\\|";"\\\\|") | gsub("`";"\\\\`") | gsub("<";"<") | gsub(">";">") | gsub("\r?\n";" ") | .[0:240]), + ((.packageName // "") + " " + (.packageVersion // "")) ] + | @tsv') + local total + total=$(echo "$cves_json" | jq 'length') + COMMENT+="
${summary}"$'\n\n' + COMMENT+=$'| CVE | Description | Package |\n|---|---|---|\n' while IFS=$'\t' read -r CVE DESC PKG; do [ -z "${CVE}" ] && CVE="" [ -z "${PKG}" ] && PKG="" - buf+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' + COMMENT+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' done <<< "$rows" if [ "$total" -gt "$MAX_ROWS" ]; then - buf+="_…and $((total - MAX_ROWS)) more — see the full list in the Upwind Console._"$'\n' - fi - [ "$collapse" -eq 1 ] && buf+=$'
\n' - buf+=$'\n' - - # Size guard: only append this table if it fits within the budget. - if [ $((${#COMMENT} + ${#buf})) -gt "$COMMENT_BUDGET" ]; then - if [ "$TRUNCATED" -ne 1 ]; then - COMMENT+=$'_Detailed CVE tables were truncated to stay within GitHub\'s comment size limit — see the full list in the Upwind Console._\n\n' - TRUNCATED=1 - fi - return 0 + COMMENT+="_…and $((total - MAX_ROWS)) more — see the Upwind Console._"$'\n' fi + COMMENT+=$'
\n\n' + } - COMMENT+="$buf" + # 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} ${label} · ${n} finding${sfx}" } - # 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"') - - # Full set of CVEs present in the image for this branch = - # newly introduced + already-present (no_change). This is the whole - # image, NOT just the diff vs the previously scanned image. - CVES=$(echo "$scan" | jq -c '(.introducedCves // []) + (.noChangeCves // [])') - - T=$(echo "$CVES" | jq 'length') - N=$(echo "$scan" | jq '(.introducedCves // []) | length') - O=$(echo "$scan" | jq '(.noChangeCves // []) | length') - R=$(echo "$scan" | jq '(.resolvedCves // []) | length') - H=$(echo "$CVES" | jq '[.[] | select((.severity // "" | ascii_upcase)=="HIGH")] | length') - C=$(echo "$CVES" | jq '[.[] | select((.severity // "" | ascii_upcase)=="CRITICAL")] | length') - OTHER=$(echo "$CVES" | jq '[.[] | select(((.severity // "" | ascii_upcase)!="CRITICAL") and ((.severity // "" | ascii_upcase)!="HIGH"))] | length') - - if [ "$T" -eq 0 ]; then - CLEAN_ARCHES+="- \`$ARCH\` (status: \`$STATUS\`)"$'\n' - continue + INTRO=$(echo "$scan" | jq -c '.introducedCves // []') + RESOLVED=$(echo "$scan" | jq -c '.resolvedCves // []') + PRESENT=$(echo "$scan" | jq -c '(.introducedCves // []) + (.noChangeCves // [])') + + 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') + + if [ "$ARCH_COUNT" -gt 1 ] && [ -n "$ARCH" ]; then + COMMENT+="## $ARCH"$'\n\n' fi - # Per-arch summary (always shown — this is the whole-image picture) - COMMENT+="## $ARCH"$'\n' - COMMENT+="- **Status:** \`$STATUS\`"$'\n' - COMMENT+="- **Total CVEs in image:** \`$T\` (\`$N\` new since last scan · \`$O\` already present · \`$R\` resolved)"$'\n' - COMMENT+="- **Critical:** \`$C\`, **High:** \`$H\`, **Medium/Low/Other:** \`$OTHER\`"$'\n\n' - - build_table "CRITICAL" - build_table "HIGH" - build_table "OTHER" + # 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' + + # 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" "✅ Resolved · ${R} finding${rsfx}" + fi done < <(jq -c '.[]' "$ARRAY") - # Collapsed list of totally clean arches (if any) - if [ -n "$CLEAN_ARCHES" ]; then - COMMENT+=$'
More architectures — all clear ✅'$'\n\n' - COMMENT+="$CLEAN_ARCHES"$'\n' - COMMENT+=$'
'$'\n' + # Console link + scan duration footer. + 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. - # Truncate at a newline boundary and close any unclosed
tags - # so the PR comment renders valid markdown. if [ "${#COMMENT}" -gt 65000 ]; then COMMENT="${COMMENT:0:64500}" COMMENT="${COMMENT%$'\n'*}" @@ -369,14 +371,12 @@ runs: COMMENT+=$'\n
' CLOSE_TAGS=$((CLOSE_TAGS + 1)) done - COMMENT+=$'\n\n_Comment truncated to fit GitHub\'s size limit — see the full results in the Upwind Console._' + COMMENT+=$'\n\n_Comment truncated to fit GitHub\'s size limit — see the Upwind Console._' fi echo "Posting summary comment on PR" - # Send the (potentially large) body via a file, never as a shell - # argument — a big --arg/-d would hit the OS arg-length limit - # ("Argument list too long") on multi-arch, CVE-heavy images. + # Send the body via a file, never as a shell argument. PAYLOAD_FILE="$(mktemp)" printf '%s' "$COMMENT" | jq -Rs '{ body: . }' > "$PAYLOAD_FILE" curl -L \ From d8e1d9e9ad882813909d030660370d0aa5a764b0 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 12:14:07 +0300 Subject: [PATCH 08/13] test: comment table -> CVE(link)/Package/Version/Fix; drop URL+timing --- action.yml | 59 ++++++++++++++++++++++++++---------------------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/action.yml b/action.yml index ea29b0c..3483dca 100644 --- a/action.yml +++ b/action.yml @@ -164,7 +164,6 @@ 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 }} @@ -214,15 +213,6 @@ runs: fi echo "Info: Image scan completed" - - # Record human-readable scan duration for the PR comment. - 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: @@ -231,7 +221,6 @@ runs: PR_NUMBER: ${{ inputs.pr_number }} ADD_COMMENT: ${{ inputs.add_comment }} OUTPUT_JSON: ${{ inputs.output_json }} - UPWIND_URI: ${{ inputs.upwind_uri }} MAIN_BRANCH: ${{ inputs.main_branch }} run: | if [ "$ADD_COMMENT" != "true" ]; then @@ -273,26 +262,42 @@ runs: COMMENT+="**Image:** \`$IMAGE_NAME:$IMAGE_VERSION\`"$'\n\n' # Render a collapsible table of the given CVE JSON array (capped). - # $1=cves json $2=summary line (already includes emoji/label/count) + # $1=cves json $2=summary line $3=with_fix ("1" to include a Fix column) emit_table() { - local cves_json="$1" summary="$2" rows + 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 // ""), - ((.cveDescription // "") | gsub("\\|";"\\\\|") | gsub("`";"\\\\`") | gsub("<";"<") | gsub(">";">") | gsub("\r?\n";" ") | .[0:240]), - ((.packageName // "") + " " + (.packageVersion // "")) ] + (.packageName // ""), + (.packageVersion // ""), + (.fixedInVersion // "") ] | @tsv') - local total total=$(echo "$cves_json" | jq 'length') COMMENT+="
${summary}"$'\n\n' - COMMENT+=$'| CVE | Description | Package |\n|---|---|---|\n' - while IFS=$'\t' read -r CVE DESC PKG; do + if [ "$with_fix" = "1" ]; then + COMMENT+=$'| CVE | Package | Version | Fix |\n|---|---|---|---|\n' + else + COMMENT+=$'| CVE | Package | Version |\n|---|---|---|\n' + fi + while IFS=$'\t' read -r CVE PKG VER FIX; do [ -z "${CVE}" ] && CVE="" - [ -z "${PKG}" ] && PKG="" - COMMENT+="| ${CVE} | ${DESC} | ${PKG} |"$'\n' + # 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 + COMMENT+="| ${CVE_CELL} | ${pkg_cell} | ${ver_cell} |"$'\n' + fi done <<< "$rows" if [ "$total" -gt "$MAX_ROWS" ]; then - COMMENT+="_…and $((total - MAX_ROWS)) more — see the Upwind Console._"$'\n' + COMMENT+="_…and $((total - MAX_ROWS)) more._"$'\n' fi COMMENT+=$'
\n\n' } @@ -304,7 +309,7 @@ runs: [ "$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} ${label} · ${n} finding${sfx}" + "${emoji} ${label} · ${n} finding${sfx}" "1" } while IFS= read -r scan; do @@ -349,18 +354,10 @@ runs: emit_introduced_sev "$INTRO" "LOW" "🟢" "Low" if [ "$R" -gt 0 ]; then rsfx="s"; if [ "$R" -eq 1 ]; then rsfx=""; fi - emit_table "$RESOLVED" "✅ Resolved · ${R} finding${rsfx}" + emit_table "$RESOLVED" "✅ Resolved · ${R} finding${rsfx}" "0" fi done < <(jq -c '.[]' "$ARRAY") - # Console link + scan duration footer. - 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}" From 3cd74cc8b80c3583316673348a27eab47b56f3d6 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 14:13:32 +0300 Subject: [PATCH 09/13] feat(UP-0): diff-vs-main PR comment (introduced/resolved tables + total count) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines the main_branch wiring with a diff-focused comment: 'N newly introduced · R resolved · T total in this PR vs ', a severity breakdown of the total, and collapsible tables listing ONLY introduced (by severity, CVE/Package/Version/Fix) and resolved (CVE/Package/Version). Already-present CVEs are counted in the total but not listed. CVE ids link to NVD. Skips commenting when scan output is empty (backend not ready) instead of posting an unknown:unknown placeholder. Body is sent via a file to avoid OS arg-length limits; final hard cap keeps it under GitHub's 65536-char limit. --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 3483dca..3f1abf4 100644 --- a/action.yml +++ b/action.yml @@ -153,7 +153,7 @@ runs: else RELEASE_BUCKET="releases.upwind.io" fi - UPWIND_AGENT_URL="https://$RELEASE_BUCKET/$UPWIND_AGENT/vsmain-test/$OS/$ARCH/$UPWIND_AGENT-$OS-$ARCH" # TEST ONLY: dev binary built from shiftleft#243 + UPWIND_AGENT_URL="https://$RELEASE_BUCKET/$UPWIND_AGENT/stable/$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" From 709ff6698604d0143f1dea61b91e6a4798241541 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 14:33:53 +0300 Subject: [PATCH 10/13] feat(UP-0): add Upwind Console link + scan duration footer (SCA-style) --- action.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/action.yml b/action.yml index 3f1abf4..72740af 100644 --- a/action.yml +++ b/action.yml @@ -164,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 }} @@ -213,6 +214,15 @@ runs: 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: @@ -222,6 +232,7 @@ runs: 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" @@ -358,6 +369,14 @@ runs: fi done < <(jq -c '.[]' "$ARRAY") + # 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}" From 9a0e9d43b8412bc1172e97761def2940ec658097 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 14:41:16 +0300 Subject: [PATCH 11/13] fix(UP-0): console link -> image Shift-left panel keyed by imageVersion The SCA /code panel keyed by fingerprint is for the code scan; an image scan must link to the vulnerabilities Shift-left panel keyed by imageVersion. --- action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 72740af..157393b 100644 --- a/action.yml +++ b/action.yml @@ -370,8 +370,8 @@ runs: done < <(jq -c '.[]' "$ARRAY") # 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' + if [ -n "$IMAGE_VERSION" ] && [ "$IMAGE_VERSION" != "unknown" ]; then + COMMENT+="[View full analysis in Upwind Console →](https://console.${UPWIND_URI:-upwind.io}/vulnerabilities?mainPageTab=Shift%20left&sidePanel=scan-at-build&sidePanelItemId=${IMAGE_VERSION})"$'\n\n' fi if [ -n "${SCAN_DURATION_HUMAN:-}" ]; then COMMENT+="_Scan completed in ${SCAN_DURATION_HUMAN}_"$'\n' From 5313c69515b89ca727376d43ca5559ed6e32f0a5 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 15:04:21 +0300 Subject: [PATCH 12/13] fix(UP-0): console link -> /code SCA scan-at-build panel keyed by fingerprint Confirmed against a real console URL: the scan-details deep link is /code?...secondaryTab=SCA&sidePanel=scan-at-build&sidePanelItemId= (the 64-hex SBOM fingerprint), not imageVersion. --- action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 157393b..72740af 100644 --- a/action.yml +++ b/action.yml @@ -370,8 +370,8 @@ runs: done < <(jq -c '.[]' "$ARRAY") # Footer: link to the full analysis in the Upwind Console + scan time. - if [ -n "$IMAGE_VERSION" ] && [ "$IMAGE_VERSION" != "unknown" ]; then - COMMENT+="[View full analysis in Upwind Console →](https://console.${UPWIND_URI:-upwind.io}/vulnerabilities?mainPageTab=Shift%20left&sidePanel=scan-at-build&sidePanelItemId=${IMAGE_VERSION})"$'\n\n' + 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' From 41f522e09c6f644dfb72fb8a209b04c64b904bd2 Mon Sep 17 00:00:00 2001 From: Amit Aboudi Date: Thu, 11 Jun 2026 15:23:24 +0300 Subject: [PATCH 13/13] feat(UP-0): show per-arch header + scan Status above the diff summary --- action.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 72740af..143fefc 100644 --- a/action.yml +++ b/action.yml @@ -325,6 +325,7 @@ runs: 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 // [])') @@ -338,8 +339,15 @@ runs: 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') - if [ "$ARCH_COUNT" -gt 1 ] && [ -n "$ARCH" ]; then - COMMENT+="## $ARCH"$'\n\n' + # 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).