name: Malware Scan # Supply-chain malware gate for the PolinRider / Famous Chollima campaign. # # Runs standalone on every push and pull request, and is also called by # deploy.yml as a required first job — a detection fails this workflow, which # blocks every downstream deploy job from starting. on: push: # dev and staging are already gated through deploy.yml's required # malware-scan job — no need to scan those pushes twice. branches-ignore: - dev - staging pull_request: workflow_call: secrets: TELEGRAM_BOT_TOKEN: required: false TELEGRAM_CHAT_ID: required: false permissions: contents: read # A detection on a ref should not be raced by a newer run of the same ref. concurrency: group: malware-scan-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: scan: name: Scan for PolinRider malware # Plain `self-hosted` — GitHub applies this label to every self-hosted # runner automatically. The scan is host-agnostic, unlike the deploy jobs # which pin to a branch-specific runner. runs-on: [self-hosted, dev] outputs: infected: ${{ steps.scan.outputs.infected }} steps: - name: Checkout uses: actions/checkout@v4 - name: Verify scanner rules still work # Fails if someone weakens a detection rule or introduces a false # positive against minified vendor bundles. run: node .github/scripts/scan.js --self-test - name: Scan repository id: scan run: | set -uo pipefail # Actions runs this with `bash -e`, so the non-zero exit must be # caught with `||` rather than read back from $? afterwards. STATUS=0 node .github/scripts/scan.js --json --output malware-report.json . || STATUS=$? if [ "$STATUS" -eq 0 ]; then echo "infected=false" >> "$GITHUB_OUTPUT" echo "No malware detected." exit 0 fi echo "infected=true" >> "$GITHUB_OUTPUT" # Human-readable run for the log, so the failure is legible in the UI. node .github/scripts/scan.js . || true exit 1 - name: Build alert message id: message if: failure() && steps.scan.outputs.infected == 'true' run: | set -euo pipefail FILES=$(jq -r '.results[].filePath' malware-report.json | head -20) COUNT=$(jq -r '.infectedFiles' malware-report.json) RULES=$(jq -r '[.results[].findings[] | select(.severity=="CRITICAL") | .id] | unique | join(", ")' malware-report.json) { echo "message<> "$GITHUB_OUTPUT" - name: Notify Telegram if: failure() && steps.scan.outputs.infected == 'true' env: BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} TEXT: ${{ steps.message.outputs.message }} run: | set -uo pipefail if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then echo "::warning::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification." exit 0 fi # No parse_mode: the payload contains characters Telegram's Markdown # parser would reject, and a failed notification is worse than plain text. HTTP=$(curl -sS -o /tmp/tg.out -w '%{http_code}' \ -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ --data-urlencode "chat_id=${CHAT_ID}" \ --data-urlencode "text=${TEXT}" \ --data-urlencode "disable_web_page_preview=true") || true if [ "${HTTP:-000}" != "200" ]; then echo "::warning::Telegram notification failed (HTTP ${HTTP:-000}): $(cat /tmp/tg.out 2>/dev/null | head -c 300)" else echo "Telegram alert sent." fi rm -f /tmp/tg.out - name: Upload scan report if: always() && hashFiles('malware-report.json') != '' uses: actions/upload-artifact@v4 with: name: malware-report-${{ github.run_id }} path: malware-report.json retention-days: 30 - name: Job summary if: always() run: | set -uo pipefail RESULT="${{ steps.scan.outputs.infected }}" if [ "$RESULT" = "true" ]; then { echo "## 🚨 Malware detected — deployment blocked" echo "" echo '```' jq -r '.results[] | .filePath, (.findings[] | " [\(.id)] \(.severity) — \(.description)")' \ malware-report.json 2>/dev/null | head -100 || true echo '```' } >> "$GITHUB_STEP_SUMMARY" elif [ "$RESULT" = "false" ]; then echo "## ✅ No malware detected" >> "$GITHUB_STEP_SUMMARY" else # The scan step never produced a verdict — treat as inconclusive # rather than clean, so a broken scanner is never read as a pass. echo "## ⚠️ Scan did not complete — verdict unknown" >> "$GITHUB_STEP_SUMMARY" fi