diff --git a/action.yml b/action.yml index 498a28f..143fefc 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, 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' @@ -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 }} @@ -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} \ @@ -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: @@ -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" @@ -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 // ""), + (.packageName // ""), + (.packageVersion // ""), + (.fixedInVersion // "") ] + | @tsv') + total=$(echo "$cves_json" | jq 'length') + COMMENT+="
${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 // ""), - ((.cveDescription // "") - | gsub("\\|"; "\\\\|") - | gsub("`"; "\\\\`") - | gsub("<"; "<") - | gsub(">"; ">") - | gsub("\r?\n"; " ") - | .[0:400]), - ((.packageName // "") + " " + (.packageVersion // "")) - ] - | @tsv - ') + while IFS=$'\t' read -r CVE PKG VER FIX; do + [ -z "${CVE}" ] && CVE="" + # 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 // ""), - ((.cveDescription // "") - | gsub("\\|"; "\\\\|") - | gsub("`"; "\\\\`") - | gsub("<"; "<") - | gsub(">"; ">") - | gsub("\r?\n"; " ") - | .[0:400]), - ((.packageName // "") + " " + (.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="" - [ -z "${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+=$'
\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} ${label} Β· ${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+=$'
Other severities (Medium/Low/Unknown) β€” ' - COMMENT+="\`$OTHER\` CVEs" - COMMENT+=$''$'\n\n' - build_table "OTHER" "Other severities" - COMMENT+=$'
'$'\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" "βœ… Resolved Β· ${R} finding${rsfx}" "0" 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' + # 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 '
' <<< "$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 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"