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" {}