From a5023e7a60eaeef76b4e0067924948315fe49a9d Mon Sep 17 00:00:00 2001 From: Sennay Date: Thu, 11 Jun 2026 10:48:39 +0300 Subject: [PATCH] Add malware and obfuscation scanning workflow --- .github/workflows/malware-scan.yaml | 256 ++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 .github/workflows/malware-scan.yaml diff --git a/.github/workflows/malware-scan.yaml b/.github/workflows/malware-scan.yaml new file mode 100644 index 000000000..01dbef1f2 --- /dev/null +++ b/.github/workflows/malware-scan.yaml @@ -0,0 +1,256 @@ +name: Malware & Obfuscation Scan + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + # Allow manual triggering for ad-hoc full scans + workflow_dispatch: + inputs: + scan_path: + description: "Sub-directory to scan (leave blank for full repo)" + required: false + default: "." + +# Prevent concurrent scans on the same ref from stepping on each other +concurrency: + group: malware-scan-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Needed if you later add GitHub Code Scanning / SARIF upload + security-events: write + +jobs: + malware-scan: + name: Scan for malicious / obfuscated code + runs-on: self-hosted + timeout-minutes: 15 + + steps: + # ── 1. Checkout ──────────────────────────────────────────────────────── + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Full history lets the scanner see every file, not just the diff. + # For very large repos you can set fetch-depth: 1 to speed things up, + # but you may miss injected files in unchanged paths. + fetch-depth: 0 + + # ── 2. Setup Node ────────────────────────────────────────────────────── + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + # ── 3. Install scanner ───────────────────────────────────────────────── + # The scanner is pure Node.js stdlib — no npm install needed. + # We just copy the script into a known location inside the runner. + - name: Install scanner script + run: | + mkdir -p "$RUNNER_TOOL_CACHE/malware-scanner" + cp .github/scripts/scan-malware.js \ + "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" + chmod +x "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" + + # ── 4. Run the scanner ───────────────────────────────────────────────── + - name: Run malware scanner + id: scan + env: + SCAN_JSON_OUT: ${{ runner.temp }}/scan-results.json + run: | + SCAN_PATH="${{ github.event.inputs.scan_path || '.' }}" + echo "Scanning path: $SCAN_PATH" + echo "────────────────────────────────────────" + + node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" "$SCAN_PATH" + # The script exits 0 (clean), 1 (threats found), or 2 (internal error). + # We want the step to "succeed" so the upload-artifact step always runs, + # but we'll fail the job in the gate step below. + continue-on-error: true + + # ── 5. Upload JSON report as artifact (always, even on failure) ──────── + # Retained so the full per-file, per-rule detail is always downloadable. + # The Telegram message below links directly to the Actions run where + # this artifact appears. + - name: Upload scan report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: malware-scan-report-${{ github.sha }} + path: ${{ runner.temp }}/scan-results.json + retention-days: 90 + if-no-files-found: ignore + + # ── 6. Check whether any critical findings were reported ──────────────── + - name: Check for critical findings + id: critical + if: always() + env: + SCAN_JSON: ${{ runner.temp }}/scan-results.json + run: | + node - << 'EOF' + const fs = require('fs'); + + let findings = []; + try { + findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8')); + } catch { /* missing file = clean run or scanner error */ } + + const criticalCount = findings.filter(f => f.severity === 'CRITICAL').length; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `count=${criticalCount}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_critical=${criticalCount > 0}\n`); + console.log(`Critical findings: ${criticalCount}`); + EOF + + # ── 7. Send Telegram notification for critical findings only ───────────── + # Requires two repository secrets: + # TELEGRAM_BOT_TOKEN — from @BotFather (format: 123456:ABC-xxx) + # TELEGRAM_CHAT_ID — target chat/channel ID (format: -100xxxxxxxxxx) + # + # Intentionally short — plain HTML mode, no code spans, no snippets. + # All special characters that would break MarkdownV2 are avoided entirely. + # Full details are in the artifact linked via the Actions run URL. + - name: Send Telegram notification + if: always() && steps.critical.outputs.has_critical == 'true' + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + SCAN_JSON: ${{ runner.temp }}/scan-results.json + GH_REPO: ${{ github.repository }} + GH_SHA: ${{ github.sha }} + GH_REF: ${{ github.ref_name }} + GH_ACTOR: ${{ github.actor }} + GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + node - << 'EOF' + const fs = require('fs'); + const https = require('https'); + + const token = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + const repo = process.env.GH_REPO; + const sha = process.env.GH_SHA.slice(0, 7); + const ref = process.env.GH_REF; + const actor = process.env.GH_ACTOR; + const runUrl = process.env.GH_RUN_URL; + + // HTML-escape only the four characters HTML cares about. + // Using HTML parse_mode means code snippets, file paths, and rule IDs + // with special characters can never break the parser. + const h = s => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + let findings = []; + try { + findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8')); + } catch { /* missing file = clean run or scanner error */ } + + const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; + for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; + + const criticalFindings = findings.filter(f => f.severity === 'CRITICAL'); + + // ── Unique affected files ────────────────────────────────────────── + const affectedFiles = [...new Set(criticalFindings.map(f => f.file))]; + + // ── Build a short, fixed-size message ───────────────────────────── + // No snippets, no descriptions — just counts, affected files, and a + // direct link to the artifact. Stays well under 500 chars. + let lines = []; + + lines.push('🚨 Malware Scan — CRITICAL THREATS DETECTED'); + + lines.push(''); + lines.push(`Repo: ${h(repo)}`); + lines.push(`Branch: ${h(ref)} Commit: ${h(sha)}`); + lines.push(`Actor: ${h(actor)}`); + + lines.push(''); + lines.push( + `Findings: ` + + `🔴 ${counts.CRITICAL} CRITICAL ` + + `🟠 ${counts.HIGH} HIGH ` + + `🟡 ${counts.MEDIUM} MEDIUM ` + + `⚪ ${counts.LOW} LOW` + ); + lines.push(''); + lines.push(`Critical affected files (${affectedFiles.length}):`); + // Cap at 10 files to keep the message short + const shown = affectedFiles.slice(0, 10); + for (const f of shown) lines.push(` • ${h(f)}`); + if (affectedFiles.length > 10) { + lines.push(` • … and ${affectedFiles.length - 10} more`); + } + + lines.push(''); + lines.push(`📋 View full run & download report artifact`); + + const text = lines.join('\n'); + + // ── Send via Bot API (HTML parse mode) ──────────────────────────── + const body = JSON.stringify({ + chat_id: chatId, + text, + parse_mode: 'HTML', + disable_web_page_preview: true, + }); + + const options = { + hostname: 'api.telegram.org', + path: `/bot${token}/sendMessage`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }; + + const req = https.request(options, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + const parsed = JSON.parse(data); + if (!parsed.ok) { + console.error('Telegram API error:', JSON.stringify(parsed)); + process.exit(1); + } + console.log('Telegram notification sent successfully.'); + }); + }); + req.on('error', err => { + console.error('Request failed:', err.message); + process.exit(1); + }); + req.write(body); + req.end(); + EOF + + # ── 8. Gate — fail the workflow if critical findings were found ───────── + # Runs after the Telegram step so the alert always fires first. + - name: Fail workflow if critical threats detected + if: always() && steps.critical.outputs.has_critical == 'true' + run: | + echo "::error::⛔ Critical malicious or highly-suspicious code patterns were detected." + echo "::error::Check your Telegram channel for the summary." + echo "::error::Download the 'malware-scan-report' artifact for full details." + echo "::error::Do NOT merge or deploy this branch until findings are reviewed." + exit 1 + + # ── 9. (Optional) Diff-only scan on PRs for faster feedback ─────────── + # Uncomment this block if you want a second, faster pass that only + # checks the files changed in the PR diff. + # + # - name: Diff-only scan (PR only) + # if: github.event_name == 'pull_request' + # env: + # SCAN_JSON_OUT: ${{ runner.temp }}/scan-results-diff.json + # run: | + # git diff --name-only origin/${{ github.base_ref }}...HEAD \ + # | grep -E '\.(js|cjs|mjs|ts|tsx|jsx)$' \ + # | xargs -I{} node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" {}