diff --git a/.github/scripts/validate/report.sh b/.github/scripts/validate/report.sh index dc6d07c..4d3b60c 100755 --- a/.github/scripts/validate/report.sh +++ b/.github/scripts/validate/report.sh @@ -225,6 +225,7 @@ fi if [[ -n "${CODEQL_RESULT:-}" && "${CODEQL_RESULT:-}" != "skipped" && "${CODEQL_RESULT:-}" != "success" ]] || \ [[ -n "${CODEQL_MEDIUMS:-}" && "${CODEQL_MEDIUMS}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]] || \ [[ -n "${CODEQL_LOWS:-}" && "${CODEQL_LOWS}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]] || \ + [[ -n "${CODEQL_SUPPRESSED:-}" && "${CODEQL_SUPPRESSED}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]] || \ [[ "${CODEQL_RESULT:-}" == "skipped" && -n "${CODEQL_UNSCANNED_LANGS:-}" ]] || \ [[ "${CODEQL_RESULT:-}" != "skipped" && -n "${CODEQL_RESULT:-}" && -n "${CODEQL_UNSCANNED_LANGS:-}" ]] || \ [[ "${CLAMAV_RESULT:-}" == "failure" ]]; then @@ -280,6 +281,15 @@ fi echo "" fi + if [[ -n "${CODEQL_SUPPRESSED:-}" && "${CODEQL_SUPPRESSED:-}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]]; then + echo "" + echo "**${CODEQL_SUPPRESSED} finding(s) suppressed via inline \`codeql[...]\` comment** - requires maintainer review before merging; auto-merge is blocked for this PR." + echo "" + if [[ -f "codeql-suppressed-findings/codeql-suppressed-findings.md" ]]; then + cat "codeql-suppressed-findings/codeql-suppressed-findings.md" + fi + fi + # CodeQL skipped notice (when no scannable files exist but unscannable types were found) if [[ "${CODEQL_RESULT:-}" == "skipped" && -n "${CODEQL_UNSCANNED_LANGS:-}" ]]; then UNSCANNED_DISPLAY=$(echo "${CODEQL_UNSCANNED_LANGS}" | tr ',' ' ') diff --git a/.github/workflows/auto-merge-updates.yml b/.github/workflows/auto-merge-updates.yml index 92934ca..3329c1e 100644 --- a/.github/workflows/auto-merge-updates.yml +++ b/.github/workflows/auto-merge-updates.yml @@ -123,6 +123,7 @@ jobs: has_label "Repo Update" && { echo "::notice::Has 'Repo Update' label - skipping."; OK=false; } has_label "Invalid" && { echo "::notice::Has 'Invalid' label - skipping."; OK=false; } has_label "QUARANTINE" && { echo "::notice::Has 'QUARANTINE' label - skipping."; OK=false; } + has_label "CodeQL Suppression Used" && { echo "::notice::PR uses a CodeQL suppression comment - skipping auto-merge, requires maintainer review."; OK=false; } [[ "$MERGEABLE" == "MERGEABLE" ]] || { echo "::notice::PR not cleanly mergeable (mergeable=$MERGEABLE) - skipping."; OK=false; } echo "ok=$OK" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index b21d602..71929c7 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -416,6 +416,7 @@ jobs: codeql_warnings: ${{ steps.status.outputs.codeql_warnings }} codeql_mediums: ${{ steps.status.outputs.codeql_mediums }} codeql_lows: ${{ steps.status.outputs.codeql_lows }} + codeql_suppressed: ${{ steps.status.outputs.codeql_suppressed }} codeql_unscanned_langs: ${{ steps.status.outputs.codeql_unscanned_langs }} steps: - name: Checkout PR merge commit for analysis @@ -555,6 +556,9 @@ jobs: run: | # Only block on security findings with CVSS score >= 7.0 (HIGH or CRITICAL). # CodeQL stores this in rule properties["security-severity"], not in result.level. + # A result with a non-empty .suppressions array was recognized by CodeQL's own + # engine as validly suppressed (e.g. a correctly-placed inline `codeql[...]` + # comment) - trust that signal and never let it count as blocking/medium/low. JQ_BLOCKING=' [ .runs[] | . as $run | @@ -564,6 +568,7 @@ jobs: | from_entries ) as $secmap | ($run.results // [])[] | + select((.suppressions // []) | length == 0) | ((.ruleId // .rule.id // "") | tostring) as $rid | select((($secmap[$rid] // "0") | tonumber) >= 7.0) ] | length' @@ -576,6 +581,7 @@ jobs: | from_entries ) as $secmap | ($run.results // [])[] | + select((.suppressions // []) | length == 0) | ((.ruleId // .rule.id // "") | tostring) as $rid | (($secmap[$rid] // "0") | tonumber) as $sev | select($sev >= 6.0 and $sev < 7.0) @@ -589,13 +595,17 @@ jobs: | from_entries ) as $secmap | ($run.results // [])[] | + select((.suppressions // []) | length == 0) | ((.ruleId // .rule.id // "") | tostring) as $rid | (($secmap[$rid] // "0") | tonumber) as $sev | select($sev < 6.0) ] | length' + JQ_SUPPRESSED=' + [ .runs[] | (.results // [])[] | select((.suppressions // []) | length > 0) ] | length' RESULT_COUNT=0 MEDIUM_COUNT=0 LOW_COUNT=0 + SUPPRESSED_COUNT=0 TOTAL_COUNT=0 if [[ -d "sarif-results" ]]; then for f in sarif-results/*.sarif sarif-results/*.sarif.gz; do @@ -608,28 +618,32 @@ jobs: COUNT=$(echo "$CONTENT" | jq "$JQ_BLOCKING") MED=$(echo "$CONTENT" | jq "$JQ_MEDIUM") LOW=$(echo "$CONTENT" | jq "$JQ_LOW") + SUPPRESSED=$(echo "$CONTENT" | jq "$JQ_SUPPRESSED") TOT=$(echo "$CONTENT" | jq '[.runs[] | (.results // [])[]] | length') - echo "File $f: ${COUNT:-0} blocking, ${MED:-0} medium, ${LOW:-0} low, $TOT total" + echo "File $f: ${COUNT:-0} blocking, ${MED:-0} medium, ${LOW:-0} low, ${SUPPRESSED:-0} suppressed, $TOT total" echo "$CONTENT" | jq -r \ '[ .runs[] | . as $run | ([ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] | from_entries) as $secmap | ($run.results // [])[] | + select((.suppressions // []) | length == 0) | ((.ruleId // .rule.id // "") | tostring) as $rid | select((($secmap[$rid] // "0") | tonumber) >= 7.0) | " [blocking] \($rid) sec-sev=\($secmap[$rid] // "n/a")" ] | .[]' || true RESULT_COUNT=$((RESULT_COUNT + ${COUNT:-0})) MEDIUM_COUNT=$((MEDIUM_COUNT + ${MED:-0})) LOW_COUNT=$((LOW_COUNT + ${LOW:-0})) + SUPPRESSED_COUNT=$((SUPPRESSED_COUNT + ${SUPPRESSED:-0})) TOTAL_COUNT=$((TOTAL_COUNT + ${TOT:-0})) done fi WARN_COUNT=$(( TOTAL_COUNT > RESULT_COUNT ? TOTAL_COUNT - RESULT_COUNT : 0 )) - echo "Found $RESULT_COUNT high/critical, $MEDIUM_COUNT medium, $LOW_COUNT low, and $WARN_COUNT other CodeQL result(s)" + echo "Found $RESULT_COUNT high/critical, $MEDIUM_COUNT medium, $LOW_COUNT low, $SUPPRESSED_COUNT suppressed, and $WARN_COUNT other CodeQL result(s)" echo "codeql_errors=$RESULT_COUNT" >> "$GITHUB_OUTPUT" echo "codeql_warnings=$WARN_COUNT" >> "$GITHUB_OUTPUT" echo "codeql_mediums=$MEDIUM_COUNT" >> "$GITHUB_OUTPUT" echo "codeql_lows=$LOW_COUNT" >> "$GITHUB_OUTPUT" + echo "codeql_suppressed=$SUPPRESSED_COUNT" >> "$GITHUB_OUTPUT" # Build list of external plugin path prefixes so findings links are suppressed # for files that came from a downloaded ZIP and don't exist in this repo. @@ -668,12 +682,13 @@ jobs: | from_entries ) as $secmap | ($run.results // [])[] | + select((.suppressions // []) | length == 0) | . as $result | (($result.ruleId // $result.rule.id // "") | tostring) as $rid | select((($secmap[$rid] // "0") | tonumber) >= 7.0) | (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | - (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]") | if length > 150 then .[0:150] + "\u2026" else . end) as $msg | + (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]")) as $msg | ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | "| `\($rid)` | \($loc) | \($msg) |"' @@ -705,13 +720,14 @@ jobs: | from_entries ) as $secmap | ($run.results // [])[] | + select((.suppressions // []) | length == 0) | . as $result | (($result.ruleId // $result.rule.id // "") | tostring) as $rid | (($secmap[$rid] // "0") | tonumber) as $sev | select($sev >= 6.0 and $sev < 7.0) | (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | - (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]") | if length > 150 then .[0:150] + "\u2026" else . end) as $msg | + (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]")) as $msg | ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | "| `\($rid)` | \($loc) | \($msg) |"' @@ -743,19 +759,54 @@ jobs: | from_entries ) as $secmap | ($run.results // [])[] | + select((.suppressions // []) | length == 0) | . as $result | (($result.ruleId // $result.rule.id // "") | tostring) as $rid | (($secmap[$rid] // "0") | tonumber) as $sev | select($sev < 6.0) | (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | - (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]") | if length > 150 then .[0:150] + "\u2026" else . end) as $msg | + (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]")) as $msg | ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | "| `\($rid)` | \($loc) | \($msg) |"' done } > codeql-low-findings.md fi + # Generate suppressed findings detail file for informational display in the PR comment. + # These were excluded from the blocking/medium/low tables above because CodeQL's own + # engine recognized and applied an inline suppression comment. + if [[ "$SUPPRESSED_COUNT" -gt 0 ]]; then + MERGE_SHA=${MERGE_SHA:-$(git rev-parse HEAD)} + { + echo "| Rule | Location | Description |" + echo "|------|----------|-------------|" + for f in sarif-results/*.sarif sarif-results/*.sarif.gz; do + [[ -f "$f" ]] || continue + if [[ "$f" == *.gz ]]; then + FC=$(gunzip -c "$f") + else + FC=$(cat "$f") + fi + echo "$FC" | jq -r \ + --arg repo "$GITHUB_REPOSITORY" \ + --arg sha "$MERGE_SHA" \ + --argjson external_prefixes "$EXTERNAL_PREFIXES" \ + '.runs[] | + . as $run | + ($run.results // [])[] | + select((.suppressions // []) | length > 0) | + . as $result | + (($result.ruleId // $result.rule.id // "") | tostring) as $rid | + (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | + ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | + (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]")) as $msg | + ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | + (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | + "| `\($rid)` | \($loc) | \($msg) |"' + done + } > codeql-suppressed-findings.md + fi ANALYZE_FAILED=false if [[ "${{ steps.detect-langs.outputs.found }}" == 'true' && "${{ steps.analyze.outcome }}" != "success" && "${{ steps.analyze.outcome }}" != "" ]]; then ANALYZE_FAILED=true @@ -810,6 +861,34 @@ jobs: path: codeql-low-findings.md if-no-files-found: ignore + - name: Upload suppressed findings detail for PR comment + if: always() + uses: actions/upload-artifact@v7 + with: + name: codeql-suppressed-findings + path: codeql-suppressed-findings.md + if-no-files-found: ignore + + - name: Apply/clear CodeQL suppression label + # Purely informational for auto-merge gating (see auto-merge-updates.yml) - never + # fails the job, so a maintainer can still merge by hand after reviewing. + if: always() + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SUPPRESSED_COUNT: ${{ steps.status.outputs.codeql_suppressed }} + run: | + gh label create "CodeQL Suppression Used" \ + --color "FBCA04" \ + --description "PR relies on an inline CodeQL suppression comment - requires maintainer review" \ + --repo "$GH_REPO" 2>/dev/null || true + if [[ "${SUPPRESSED_COUNT:-0}" -gt 0 ]]; then + gh pr edit "$PR_NUMBER" --add-label "CodeQL Suppression Used" --repo "$GH_REPO" + else + gh pr edit "$PR_NUMBER" --remove-label "CodeQL Suppression Used" --repo "$GH_REPO" 2>/dev/null || true + fi + - name: Fail job if CodeQL found high/error/critical issues if: always() && steps.status.outputs.codeql_status == 'failure' run: exit 1 @@ -1189,6 +1268,13 @@ jobs: path: codeql-low-findings continue-on-error: true + - name: Download CodeQL suppressed findings detail + uses: actions/download-artifact@v8 + with: + name: codeql-suppressed-findings + path: codeql-suppressed-findings + continue-on-error: true + - name: Download ClamAV findings detail uses: actions/download-artifact@v8 with: @@ -1207,6 +1293,7 @@ jobs: CODEQL_WARNINGS: ${{ needs.codeql-analyze.outputs.codeql_warnings }} CODEQL_MEDIUMS: ${{ needs.codeql-analyze.outputs.codeql_mediums }} CODEQL_LOWS: ${{ needs.codeql-analyze.outputs.codeql_lows }} + CODEQL_SUPPRESSED: ${{ needs.codeql-analyze.outputs.codeql_suppressed }} CODEQL_UNSCANNED_LANGS: ${{ needs.codeql-analyze.outputs.codeql_unscanned_langs }} CLAMAV_RESULT: ${{ needs.clamav-scan.outputs.clamav_status }} CLAMAV_INFECTED: ${{ needs.clamav-scan.outputs.clamav_infected }}