Files
edr-platform/.github/workflows/malware-scan.yml
2026-08-07 12:03:14 +03:00

162 lines
5.8 KiB
YAML

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<<EOF"
echo "🚨 POLINRIDER MALWARE DETECTED — DEPLOY BLOCKED"
echo ""
echo "Repo: ${GITHUB_REPOSITORY}"
echo "Branch: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
echo "Author: ${GITHUB_ACTOR}"
echo ""
echo "Infected files (${COUNT}):"
echo "${FILES}"
echo ""
echo "Critical rules: ${RULES:-none}"
echo ""
echo "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo ""
echo "Do NOT run pnpm install or any build on this checkout."
echo "Rotate every secret this repo's CI can reach."
echo "EOF"
} >> "$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