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