Files
openclaw/src/security/skill-scanner.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

442 lines
12 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SkillScanSeverity = "info" | "warn" | "critical";
export type SkillScanFinding = {
ruleId: string;
severity: SkillScanSeverity;
file: string;
line: number;
message: string;
evidence: string;
};
export type SkillScanSummary = {
scannedFiles: number;
critical: number;
warn: number;
info: number;
findings: SkillScanFinding[];
};
export type SkillScanOptions = {
includeFiles?: string[];
maxFiles?: number;
maxFileBytes?: number;
};
// ---------------------------------------------------------------------------
// Scannable extensions
// ---------------------------------------------------------------------------
const SCANNABLE_EXTENSIONS = new Set([
".js",
".ts",
".mjs",
".cjs",
".mts",
".cts",
".jsx",
".tsx",
]);
const DEFAULT_MAX_SCAN_FILES = 500;
const DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
export function isScannable(filePath: string): boolean {
return SCANNABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function isErrno(err: unknown, code: string): boolean {
if (!err || typeof err !== "object") {
return false;
}
if (!("code" in err)) {
return false;
}
return (err as { code?: unknown }).code === code;
}
// ---------------------------------------------------------------------------
// Rule definitions
// ---------------------------------------------------------------------------
type LineRule = {
ruleId: string;
severity: SkillScanSeverity;
message: string;
pattern: RegExp;
/** If set, the rule only fires when the *full source* also matches this pattern. */
requiresContext?: RegExp;
};
type SourceRule = {
ruleId: string;
severity: SkillScanSeverity;
message: string;
/** Primary pattern tested against the full source. */
pattern: RegExp;
/** Secondary context pattern; both must match for the rule to fire. */
requiresContext?: RegExp;
};
const LINE_RULES: LineRule[] = [
{
ruleId: "dangerous-exec",
severity: "critical",
message: "Shell command execution detected (child_process)",
pattern: /\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(/,
requiresContext: /child_process/,
},
{
ruleId: "dynamic-code-execution",
severity: "critical",
message: "Dynamic code execution detected",
pattern: /\beval\s*\(|new\s+Function\s*\(/,
},
{
ruleId: "crypto-mining",
severity: "critical",
message: "Possible crypto-mining reference detected",
pattern: /stratum\+tcp|stratum\+ssl|coinhive|cryptonight|xmrig/i,
},
{
ruleId: "suspicious-network",
severity: "warn",
message: "WebSocket connection to non-standard port",
pattern: /new\s+WebSocket\s*\(\s*["']wss?:\/\/[^"']*:(\d+)/,
},
];
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
const SOURCE_RULES: SourceRule[] = [
{
ruleId: "potential-exfiltration",
severity: "warn",
message: "File read combined with network send — possible data exfiltration",
pattern: /readFileSync|readFile/,
requiresContext: /\bfetch\b|\bpost\b|http\.request/i,
},
{
ruleId: "obfuscated-code",
severity: "warn",
message: "Hex-encoded string sequence detected (possible obfuscation)",
pattern: /(\\x[0-9a-fA-F]{2}){6,}/,
},
{
ruleId: "obfuscated-code",
severity: "warn",
message: "Large base64 payload with decode call detected (possible obfuscation)",
pattern: /(?:atob|Buffer\.from)\s*\(\s*["'][A-Za-z0-9+/=]{200,}["']/,
},
{
ruleId: "env-harvesting",
severity: "critical",
message:
"Environment variable access combined with network send — possible credential harvesting",
pattern: /process\.env/,
requiresContext: /\bfetch\b|\bpost\b|http\.request/i,
},
];
// ---------------------------------------------------------------------------
// Core scanner
// ---------------------------------------------------------------------------
function truncateEvidence(evidence: string, maxLen = 120): string {
if (evidence.length <= maxLen) {
return evidence;
}
return `${evidence.slice(0, maxLen)}`;
}
export function scanSource(source: string, filePath: string): SkillScanFinding[] {
const findings: SkillScanFinding[] = [];
const lines = source.split("\n");
const matchedLineRules = new Set<string>();
// --- Line rules ---
for (const rule of LINE_RULES) {
if (matchedLineRules.has(rule.ruleId)) {
continue;
}
// Skip rule entirely if context requirement not met
if (rule.requiresContext && !rule.requiresContext.test(source)) {
continue;
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = rule.pattern.exec(line);
if (!match) {
continue;
}
// Special handling for suspicious-network: check port
if (rule.ruleId === "suspicious-network") {
const port = parseInt(match[1], 10);
if (STANDARD_PORTS.has(port)) {
continue;
}
}
findings.push({
ruleId: rule.ruleId,
severity: rule.severity,
file: filePath,
line: i + 1,
message: rule.message,
evidence: truncateEvidence(line.trim()),
});
matchedLineRules.add(rule.ruleId);
break; // one finding per line-rule per file
}
}
// --- Source rules ---
const matchedSourceRules = new Set<string>();
for (const rule of SOURCE_RULES) {
// Allow multiple findings for different messages with the same ruleId
// but deduplicate exact (ruleId+message) combos
const ruleKey = `${rule.ruleId}::${rule.message}`;
if (matchedSourceRules.has(ruleKey)) {
continue;
}
if (!rule.pattern.test(source)) {
continue;
}
if (rule.requiresContext && !rule.requiresContext.test(source)) {
continue;
}
// Find the first matching line for evidence + line number
let matchLine = 0;
let matchEvidence = "";
for (let i = 0; i < lines.length; i++) {
if (rule.pattern.test(lines[i])) {
matchLine = i + 1;
matchEvidence = lines[i].trim();
break;
}
}
// For source rules, if we can't find a line match the pattern might span
// lines. Report line 0 with truncated source as evidence.
if (matchLine === 0) {
matchLine = 1;
matchEvidence = source.slice(0, 120);
}
findings.push({
ruleId: rule.ruleId,
severity: rule.severity,
file: filePath,
line: matchLine,
message: rule.message,
evidence: truncateEvidence(matchEvidence),
});
matchedSourceRules.add(ruleKey);
}
return findings;
}
// ---------------------------------------------------------------------------
// Directory scanner
// ---------------------------------------------------------------------------
function normalizeScanOptions(opts?: SkillScanOptions): Required<SkillScanOptions> {
return {
includeFiles: opts?.includeFiles ?? [],
maxFiles: Math.max(1, opts?.maxFiles ?? DEFAULT_MAX_SCAN_FILES),
maxFileBytes: Math.max(1, opts?.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES),
};
}
function isPathInside(basePath: string, candidatePath: string): boolean {
const base = path.resolve(basePath);
const candidate = path.resolve(candidatePath);
const rel = path.relative(base, candidate);
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
}
async function walkDirWithLimit(dirPath: string, maxFiles: number): Promise<string[]> {
const files: string[] = [];
const stack: string[] = [dirPath];
while (stack.length > 0 && files.length < maxFiles) {
const currentDir = stack.pop();
if (!currentDir) {
break;
}
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (files.length >= maxFiles) {
break;
}
// Skip hidden dirs and node_modules
if (entry.name.startsWith(".") || entry.name === "node_modules") {
continue;
}
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (isScannable(entry.name)) {
files.push(fullPath);
}
}
}
return files;
}
async function resolveForcedFiles(params: {
rootDir: string;
includeFiles: string[];
}): Promise<string[]> {
if (params.includeFiles.length === 0) {
return [];
}
const seen = new Set<string>();
const out: string[] = [];
for (const rawIncludePath of params.includeFiles) {
const includePath = path.resolve(params.rootDir, rawIncludePath);
if (!isPathInside(params.rootDir, includePath)) {
continue;
}
if (!isScannable(includePath)) {
continue;
}
if (seen.has(includePath)) {
continue;
}
let st: Awaited<ReturnType<typeof fs.stat>> | null = null;
try {
st = await fs.stat(includePath);
} catch (err) {
if (isErrno(err, "ENOENT")) {
continue;
}
throw err;
}
if (!st?.isFile()) {
continue;
}
out.push(includePath);
seen.add(includePath);
}
return out;
}
async function collectScannableFiles(dirPath: string, opts: Required<SkillScanOptions>) {
const forcedFiles = await resolveForcedFiles({
rootDir: dirPath,
includeFiles: opts.includeFiles,
});
if (forcedFiles.length >= opts.maxFiles) {
return forcedFiles.slice(0, opts.maxFiles);
}
const walkedFiles = await walkDirWithLimit(dirPath, opts.maxFiles);
const seen = new Set(forcedFiles.map((f) => path.resolve(f)));
const out = [...forcedFiles];
for (const walkedFile of walkedFiles) {
if (out.length >= opts.maxFiles) {
break;
}
const resolved = path.resolve(walkedFile);
if (seen.has(resolved)) {
continue;
}
out.push(walkedFile);
seen.add(resolved);
}
return out;
}
async function readScannableSource(filePath: string, maxFileBytes: number): Promise<string | null> {
let st: Awaited<ReturnType<typeof fs.stat>> | null = null;
try {
st = await fs.stat(filePath);
} catch (err) {
if (isErrno(err, "ENOENT")) {
return null;
}
throw err;
}
if (!st?.isFile() || st.size > maxFileBytes) {
return null;
}
try {
return await fs.readFile(filePath, "utf-8");
} catch (err) {
if (isErrno(err, "ENOENT")) {
return null;
}
throw err;
}
}
export async function scanDirectory(
dirPath: string,
opts?: SkillScanOptions,
): Promise<SkillScanFinding[]> {
const scanOptions = normalizeScanOptions(opts);
const files = await collectScannableFiles(dirPath, scanOptions);
const allFindings: SkillScanFinding[] = [];
for (const file of files) {
const source = await readScannableSource(file, scanOptions.maxFileBytes);
if (source == null) {
continue;
}
const findings = scanSource(source, file);
allFindings.push(...findings);
}
return allFindings;
}
export async function scanDirectoryWithSummary(
dirPath: string,
opts?: SkillScanOptions,
): Promise<SkillScanSummary> {
const scanOptions = normalizeScanOptions(opts);
const files = await collectScannableFiles(dirPath, scanOptions);
const allFindings: SkillScanFinding[] = [];
let scannedFiles = 0;
for (const file of files) {
const source = await readScannableSource(file, scanOptions.maxFileBytes);
if (source == null) {
continue;
}
scannedFiles += 1;
const findings = scanSource(source, file);
allFindings.push(...findings);
}
return {
scannedFiles,
critical: allFindings.filter((f) => f.severity === "critical").length,
warn: allFindings.filter((f) => f.severity === "warn").length,
info: allFindings.filter((f) => f.severity === "info").length,
findings: allFindings,
};
}