/**
 * PolinRider / Famous Chollima Supply-Chain Malware Scanner
 *
 * Detects the specific injection pattern used in the PolinRider campaign
 * attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342).
 *
 * IOCs sourced from:
 *  - Direct analysis of the injected tailwind.config.js sample
 *  - Socket Security report (May 2026) on roberts/leads compromise
 *  - PolinRider technical analysis report (Trend Micro / safedep.io)
 *
 * Zero external dependencies — runs on any Node.js >= 14.
 */

"use strict";

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

// ─── Configuration ────────────────────────────────────────────────────────────

const CONFIG = {
  // Maximum legitimate size for JS config files.
  // Real tailwind/postcss/babel configs are rarely > 3 KB.
  // Injected files jump to 5–8 KB instantly.
  maxLegitConfigBytes: 3072,

  // Minimum whitespace run on a single line that signals hidden payload.
  // The campaign uses ~280–510 spaces to push payload off-screen.
  minSuspiciousInlineSpaces: 100,

  // Files that are high-value injection targets for this campaign.
  targetedFilenames: [
    "tailwind.config.js",
    "tailwind.config.ts",
    "tailwind.config.cjs",
    "tailwind.js",
    "postcss.config.js",
    "postcss.config.mjs",
    "postcss.config.cjs",
    "babel.config.js",
    "babel.config.cjs",
    "next.config.js",
    "next.config.mjs",
    "next.config.cjs",
    "astro.config.mjs",
    "astro.config.js",
    "vite.config.js",
    "vite.config.ts",
    "webpack.config.js",
    "webpack.mix.js",
  ],

  // Files intentionally containing malware indicators for scanner logic/tests.
  // These filenames are skipped before malware rules are evaluated.
  ignoredFilenames: ["scan.js"],

  // Filesystem paths that indicate active persistence mechanisms
  persistenceArtifacts: [
    "temp_auto_push.bat",
    "temp_interactive_push.bat",
    "branch_structure.json",
    // Note: queue.bat and .plist are OS-level; checked separately
  ],
};

// ─── IOC Definitions ──────────────────────────────────────────────────────────

/**
 * Each rule has:
 *   id          – unique rule identifier for reporting
 *   severity    – CRITICAL | HIGH | MEDIUM
 *   description – human-readable explanation
 *   test(content, filePath, lines) – returns array of match details or []
 */
const RULES = [
  // ── Tier 1: Definitive campaign signatures ─────────────────────────────────

  {
    id: "POLINRIDER-001",
    severity: "CRITICAL",
    description:
      "PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign",
    test(content) {
      const matches = [];
      const re = /_\$_1e42/g;
      let m;
      while ((m = re.exec(content)) !== null) {
        matches.push(`offset ${m.index}`);
      }
      return matches;
    },
  },

  {
    id: "POLINRIDER-002",
    severity: "CRITICAL",
    description:
      "PolinRider campaign marker global['!'] assignment — used to route C2 traffic",
    test(content) {
      const matches = [];
      const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g;
      let m;
      while ((m = re.exec(content)) !== null) {
        const snippet = content
          .slice(m.index, m.index + 40)
          .replace(/\n/g, "\\n");
        matches.push(`"${snippet}"`);
      }
      return matches;
    },
  },

  {
    id: "POLINRIDER-003",
    severity: "CRITICAL",
    description:
      'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo',
    test(content) {
      return content.includes("rmcej%otb%") ? ["seed string found"] : [];
    },
  },

  {
    id: "POLINRIDER-004",
    severity: "CRITICAL",
    description:
      "Known C2 IP addresses associated with PolinRider infrastructure",
    test(content) {
      const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"];
      return knownC2.filter((ip) => content.includes(ip));
    },
  },

  {
    id: "POLINRIDER-005",
    severity: "CRITICAL",
    description:
      "Known TRON blockchain wallet addresses used as dead-drop C2 resolvers",
    test(content) {
      const wallets = [
        "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP",
        "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG",
      ];
      return wallets.filter((w) => content.includes(w));
    },
  },

  {
    id: "POLINRIDER-006",
    severity: "CRITICAL",
    description:
      "Known Aptos blockchain addresses used as fallback dead-drop resolvers",
    test(content) {
      const addrs = [
        "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e",
        "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3",
      ];
      return addrs.filter((a) => content.includes(a));
    },
  },

  {
    id: "POLINRIDER-007",
    severity: "CRITICAL",
    description:
      "Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions",
    test(content) {
      const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"];
      return keys.filter((k) => content.includes(k));
    },
  },

  {
    id: "POLINRIDER-008",
    severity: "CRITICAL",
    description:
      "Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)",
    test(content) {
      const knownHashes = new Set([
        "96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3",
        "522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f",
      ]);
      const hash = crypto.createHash("sha256").update(content).digest("hex");
      return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : [];
    },
  },

  // ── Tier 2: Behavioral / structural indicators ─────────────────────────────

  {
    id: "POLINRIDER-009",
    severity: "HIGH",
    description:
      "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers",
    test(content) {
      const endpoints = [
        "trongrid.io",
        "aptoslabs.com",
        "bsc-dataseed.binance.org",
        "bsc-rpc.publicnode.com",
        "eth_getTransactionByHash",
      ];
      return endpoints.filter((e) => content.includes(e));
    },
  },

  {
    id: "POLINRIDER-010",
    severity: "HIGH",
    description:
      "Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly",
    test(content) {
      return /windowsHide\s*:\s*true/.test(content)
        ? ["windowsHide:true found"]
        : [];
    },
  },

  {
    id: "POLINRIDER-011",
    severity: "HIGH",
    description:
      "Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements",
    test(content) {
      const matches =
        content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || [];
      return matches.length >= 2
        ? [`Found ${matches.length} duplicate createRequire imports`]
        : [];
    },
  },

  {
    id: "POLINRIDER-012",
    severity: "HIGH",
    description:
      "Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views",
    test(content, _filePath, lines) {
      const hits = [];
      lines.forEach((line, i) => {
        const spaceRun = line.match(/\s{100,}/);
        if (spaceRun) {
          hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`);
        }
      });
      return hits;
    },
  },

  {
    id: "POLINRIDER-013",
    severity: "HIGH",
    description:
      "Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB",
    test(content, filePath) {
      const bytes = Buffer.byteLength(content, "utf8");
      const base = path.basename(filePath).toLowerCase();
      const isTargeted = CONFIG.targetedFilenames.some(
        (f) => f.toLowerCase() === base,
      );
      if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) {
        return [
          `${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`,
        ];
      }
      return [];
    },
  },

  {
    id: "POLINRIDER-014",
    severity: "HIGH",
    description:
      "Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches",
    test(_content, filePath) {
      const base = path.basename(filePath);
      return CONFIG.persistenceArtifacts.includes(base) ? [base] : [];
    },
  },

  // ── Tier 3: Supporting behavioral indicators ───────────────────────────────

  {
    id: "POLINRIDER-015",
    severity: "MEDIUM",
    description:
      "Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)",
    test(content) {
      const matches = [];
      // Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928'
      const re =
        /global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g;
      let m;
      while ((m = re.exec(content)) !== null) {
        matches.push(`marker value: "${m[1]}"`);
      }
      return matches;
    },
  },

  {
    id: "POLINRIDER-016",
    severity: "MEDIUM",
    description:
      "sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant",
    test(content) {
      // sfL appears as a named function used to decode the larger payload blob
      const occurrences = (content.match(/\bsfL\b/g) || []).length;
      return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : [];
    },
  },

  {
    id: "POLINRIDER-017",
    severity: "MEDIUM",
    description:
      "global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions",
    test(content) {
      const hits = [];
      if (/global\s*\[.*\]\s*=\s*require/.test(content))
        hits.push("global[x] = require");
      if (/global\s*\[.*module.*\]\s*=\s*module/.test(content))
        hits.push("global[x] = module");
      return hits;
    },
  },
];

// ─── Scanner Engine ────────────────────────────────────────────────────────────

function scanFile(filePath) {
  if (shouldIgnoreFile(filePath)) {
    return { filePath, findings: [], skipped: true };
  }

  let content;
  try {
    content = fs.readFileSync(filePath, "utf8");
  } catch (err) {
    return { filePath, error: err.message, findings: [] };
  }

  const lines = content.split("\n");
  const findings = [];

  for (const rule of RULES) {
    let matches;
    try {
      matches = rule.test(content, filePath, lines);
    } catch (err) {
      matches = [`[rule error: ${err.message}]`];
    }

    if (matches && matches.length > 0) {
      findings.push({
        id: rule.id,
        severity: rule.severity,
        description: rule.description,
        matches,
      });
    }
  }

  return { filePath, findings };
}

function shouldIgnoreFile(filePath) {
  const base = path.basename(filePath).toLowerCase();
  return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base);
}

function walkDir(dir, results = []) {
  let entries;
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return results;
  }

  for (const entry of entries) {
    if (entry.name === "node_modules" || entry.name === ".git") continue;

    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      walkDir(full, results);
    } else if (entry.isFile() && !shouldIgnoreFile(full)) {
      const ext = path.extname(entry.name).toLowerCase();
      const base = entry.name.toLowerCase();

      // Scan all JS/TS config files + any file matching a targeted name
      const isTargetedName = CONFIG.targetedFilenames.some(
        (f) => f.toLowerCase() === base,
      );
      const isPersistenceArtifact = CONFIG.persistenceArtifacts.some(
        (f) => f.toLowerCase() === base,
      );
      const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes(
        ext,
      );

      if (isTargetedName || isPersistenceArtifact || isJsLike) {
        results.push(full);
      }
    }
  }

  return results;
}

// ─── Reporting ─────────────────────────────────────────────────────────────────

const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 };
const ANSI = {
  reset: "\x1b[0m",
  bold: "\x1b[1m",
  red: "\x1b[31m",
  yellow: "\x1b[33m",
  cyan: "\x1b[36m",
  green: "\x1b[32m",
  dim: "\x1b[2m",
};

function colorSeverity(sev) {
  if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`;
  if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`;
  return `${ANSI.cyan}${sev}${ANSI.reset}`;
}

function printReport(allResults, { json = false, outputFile = null } = {}) {
  const infected = allResults.filter(
    (r) => r.findings && r.findings.length > 0,
  );
  const errors = allResults.filter((r) => r.error);

  if (json) {
    const report = {
      scannedAt: new Date().toISOString(),
      totalFilesScanned: allResults.length,
      infectedFiles: infected.length,
      results: infected,
      errors,
    };
    const out = JSON.stringify(report, null, 2);
    if (outputFile) {
      fs.writeFileSync(outputFile, out);
      console.log(`JSON report written to: ${outputFile}`);
    } else {
      console.log(out);
    }
    return infected.length > 0;
  }

  // Human-readable output
  console.log(
    `\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`,
  );
  console.log(`║   PolinRider / Famous Chollima Malware Scanner            ║`);
  console.log(
    `╚══════════════════════════════════════════════════════════╝${ANSI.reset}`,
  );
  console.log(
    `${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`,
  );

  if (infected.length === 0) {
    console.log(
      `${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`,
    );
  } else {
    console.log(
      `${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`,
    );

    for (const result of infected) {
      // Sort findings by severity descending
      const sorted = [...result.findings].sort(
        (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity],
      );
      const topSev = sorted[0].severity;

      console.log(
        `  ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`,
      );
      for (const f of sorted) {
        console.log(
          `    ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`,
        );
        for (const m of f.matches) {
          console.log(`      → ${m}`);
        }
      }
      console.log();
    }

    console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`);
    console.log(
      `  1. Immediately isolate the affected machine from the network.`,
    );
    console.log(
      `  2. Do NOT run npm install, npm build, or any script on this repo.`,
    );
    console.log(
      `  3. Check for running node.exe / node processes with obfuscated args.`,
    );
    console.log(
      `  4. Remove all code after the legitimate config closing block.`,
    );
    console.log(
      `  5. Remove duplicate 'import { createRequire }' lines at file top.`,
    );
    console.log(
      `  6. Recover the git repository from a clean local clone (see docs).`,
    );
    console.log(
      `  7. Revoke ALL secrets, tokens, and credentials in .env and CI.`,
    );
    console.log(
      `  8. See full remediation guide in the attached incident report.\n`,
    );
  }

  if (errors.length > 0) {
    console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`);
    for (const e of errors) {
      console.log(`  ${e.filePath}: ${e.error}`);
    }
    console.log();
  }

  return infected.length > 0;
}

// ─── CLI Entry Point ───────────────────────────────────────────────────────────

function main() {
  const args = process.argv.slice(2);
  const jsonFlag = args.includes("--json");
  const outputFileIdx = args.indexOf("--output");
  const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null;

  // Positional args after flags are scan targets
  const targets = args.filter(
    (a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output",
  );

  if (targets.length === 0) {
    console.error(
      "Usage: scan.js [--json] [--output report.json] <path> [path...]",
    );
    console.error(
      "  path can be a file or directory (directories are walked recursively)",
    );
    process.exit(1);
  }

  const filesToScan = [];
  for (const target of targets) {
    if (!fs.existsSync(target)) {
      console.error(`Path not found: ${target}`);
      process.exit(1);
    }
    const stat = fs.statSync(target);
    if (stat.isDirectory()) {
      const found = walkDir(target);
      filesToScan.push(...found);
    } else {
      filesToScan.push(target);
    }
  }

  // Deduplicate
  const unique = [...new Set(filesToScan)];
  const allResults = unique.map(scanFile);

  const infected = printReport(allResults, { json: jsonFlag, outputFile });

  // Exit code 1 if any infection found — used by CI to block deployments
  process.exit(infected ? 1 : 0);
}

main();
