UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

552 lines (551 loc) 16.6 kB
import { o as hasErrnoCode } from "./errors-BXgSefBE.js"; import { i as isPathInside } from "./path-BlG8lhgR.js"; import "./scan-paths-Bve2UhXh.js"; import path from "node:path"; import fs from "node:fs/promises"; //#region src/skills/security/scanner.ts 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; const FILE_SCAN_CACHE_MAX = 5e3; const DIR_ENTRY_CACHE_MAX = 5e3; const TEST_DIRECTORY_NAMES = new Set([ "__fixtures__", "__mocks__", "__tests__", "test", "tests" ]); const TEST_FILE_NAME_PATTERN = /\.(?:mock|spec|test)\.[^.]+$/i; const FILE_SCAN_CACHE = /* @__PURE__ */ new Map(); const DIR_ENTRY_CACHE = /* @__PURE__ */ new Map(); function isScannable(filePath) { return SCANNABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } function getCachedFileScanResult(params) { const cached = FILE_SCAN_CACHE.get(params.filePath); if (!cached) return; if (cached.size !== params.size || cached.mtimeMs !== params.mtimeMs || cached.maxFileBytes !== params.maxFileBytes) { FILE_SCAN_CACHE.delete(params.filePath); return; } return cached; } function setCachedFileScanResult(filePath, entry) { if (FILE_SCAN_CACHE.size >= FILE_SCAN_CACHE_MAX) { const oldest = FILE_SCAN_CACHE.keys().next(); if (!oldest.done) FILE_SCAN_CACHE.delete(oldest.value); } FILE_SCAN_CACHE.set(filePath, entry); } function setCachedDirEntries(dirPath, entry) { if (DIR_ENTRY_CACHE.size >= DIR_ENTRY_CACHE_MAX) { const oldest = DIR_ENTRY_CACHE.keys().next(); if (!oldest.done) DIR_ENTRY_CACHE.delete(oldest.value); } DIR_ENTRY_CACHE.set(dirPath, entry); } function clearSkillScanCacheForTest() { FILE_SCAN_CACHE.clear(); DIR_ENTRY_CACHE.clear(); } const LINE_RULES = [ { 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, 3e3 ]); const NETWORK_SEND_CONTEXT_PATTERN = /\bfetch\s*\(|\bpost\s*\(|\.\s*post\s*\(|http\.request\s*\(/i; const SOURCE_RULES = [ { ruleId: "potential-exfiltration", severity: "warn", message: "File read combined with network send — possible data exfiltration", pattern: /readFileSync|readFile/, requiresContext: NETWORK_SEND_CONTEXT_PATTERN }, { 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: NETWORK_SEND_CONTEXT_PATTERN, requiresContextWindowLines: 8 } ]; const SKILL_CONTENT_RULES = [ { ruleId: "prompt-injection-ignore-instructions", severity: "critical", message: "Prompt-injection wording attempts to override higher-priority instructions", pattern: /ignore (all|any|previous|above|prior) instructions/i }, { ruleId: "prompt-injection-system", severity: "critical", message: "Skill text references hidden prompt layers", pattern: /\b(system prompt|developer message|hidden instructions)\b/i }, { ruleId: "prompt-injection-tool", severity: "critical", message: "Skill text encourages bypassing tool approval", pattern: /\b(run|execute|invoke|call)\b.{0,50}\btool\b.{0,50}\bwithout\b.{0,30}\b(permission|approval)/i }, { ruleId: "shell-pipe-to-shell", severity: "critical", message: "Skill text includes pipe-to-shell install pattern", pattern: /\b(curl|wget)\b[^|\n]{0,120}\|\s*(sh|bash|zsh)\b/i }, { ruleId: "secret-exfiltration", severity: "critical", message: "Skill text may exfiltrate environment variables", pattern: /\b(process\.env|env)\b.{0,80}\b(fetch|curl|wget|http|https)\b/i }, { ruleId: "destructive-delete", severity: "warn", message: "Skill text contains broad destructive delete command", pattern: /\brm\s+-rf\s+(\/|\$HOME|~|\.)/i }, { ruleId: "unsafe-permissions", severity: "warn", message: "Skill text contains unsafe permission change", pattern: /\bchmod\s+(-R\s+)?777\b/i } ]; function truncateEvidence(evidence, maxLen = 120) { if (evidence.length <= maxLen) return evidence; return `${evidence.slice(0, maxLen)}…`; } function isBenignMemberExecMatch(line, match) { if (match[1] !== "exec") return false; const matchIndex = match.index; if (matchIndex <= 0 || line[matchIndex - 1] !== ".") return false; return !/\b(?:cp|childProcess|child_process)\s*\.\s*exec\s*\(/.test(line); } function stripCommentsForHeuristics(source) { let stripped = ""; let quote = null; let escaped = false; let inBlockComment = false; for (let i = 0; i < source.length; i++) { const ch = source[i] ?? ""; const next = source[i + 1] ?? ""; if (inBlockComment) { if (ch === "*" && next === "/") { inBlockComment = false; i++; continue; } if (ch === "\n") stripped += "\n"; continue; } if (quote) { stripped += ch; if (escaped) escaped = false; else if (ch === "\\") escaped = true; else if (ch === quote) quote = null; continue; } if (ch === "'" || ch === "\"" || ch === "`") { quote = ch; stripped += ch; continue; } if (ch === "/" && next === "/") { while (i < source.length && source[i] !== "\n") i++; if (source[i] === "\n") stripped += "\n"; continue; } if (ch === "/" && next === "*") { inBlockComment = true; i++; continue; } stripped += ch; } return stripped; } function findSourceRuleMatch(params) { if (!params.rule.pattern.test(params.source)) return null; if (params.rule.requiresContext && !params.rule.requiresContext.test(params.source)) return null; for (let i = 0; i < params.lines.length; i++) { if (!params.rule.pattern.test(params.lines[i] ?? "")) continue; if (params.rule.requiresContext && params.rule.requiresContextWindowLines !== void 0) { const start = Math.max(0, i - params.rule.requiresContextWindowLines); const end = Math.min(params.lines.length, i + params.rule.requiresContextWindowLines + 1); const windowSource = params.lines.slice(start, end).join("\n"); if (!params.rule.requiresContext.test(windowSource)) continue; } return { line: i + 1, evidence: params.lines[i] ?? "" }; } if (params.rule.requiresContextWindowLines !== void 0) return null; return { line: 1, evidence: params.source.slice(0, 120) }; } function scanSource(source, filePath) { const findings = []; const lines = source.split("\n"); const heuristicSource = stripCommentsForHeuristics(source); const heuristicLines = heuristicSource.split("\n"); const matchedLineRules = /* @__PURE__ */ new Set(); for (const rule of LINE_RULES) { if (matchedLineRules.has(rule.ruleId)) continue; 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; if (rule.ruleId === "dangerous-exec" && isBenignMemberExecMatch(line, match)) continue; if (rule.ruleId === "suspicious-network") { const port = Number.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; } } const matchedSourceRules = /* @__PURE__ */ new Set(); for (const rule of SOURCE_RULES) { const ruleKey = `${rule.ruleId}::${rule.message}`; if (matchedSourceRules.has(ruleKey)) continue; const match = findSourceRuleMatch({ rule, source: heuristicSource, lines: heuristicLines }); if (!match) continue; findings.push({ ruleId: rule.ruleId, severity: rule.severity, file: filePath, line: match.line, message: rule.message, evidence: truncateEvidence(lines[match.line - 1]?.trim() ?? match.evidence.trim()) }); matchedSourceRules.add(ruleKey); } return findings; } function scanSkillContent(content, filePath) { const findings = []; const lines = content.split("\n"); const matchedRules = /* @__PURE__ */ new Set(); for (const rule of SKILL_CONTENT_RULES) { if (matchedRules.has(rule.ruleId)) continue; const match = findSourceRuleMatch({ rule, source: content, lines }); if (!match) continue; findings.push({ ruleId: rule.ruleId, severity: rule.severity, file: filePath, line: match.line, message: rule.message, evidence: truncateEvidence(lines[match.line - 1]?.trim() ?? match.evidence.trim()) }); matchedRules.add(rule.ruleId); } return findings; } function normalizeScanOptions(opts) { return { excludeTestFiles: opts?.excludeTestFiles ?? false, includeHiddenDirectories: opts?.includeHiddenDirectories ?? false, includeNestedNodeModulesTestFiles: opts?.includeNestedNodeModulesTestFiles ?? false, includeNodeModules: opts?.includeNodeModules ?? false, includeFiles: opts?.includeFiles ?? [], onlyIncludeFiles: opts?.onlyIncludeFiles ?? false, maxFiles: Math.max(1, opts?.maxFiles ?? DEFAULT_MAX_SCAN_FILES), maxFileBytes: Math.max(1, opts?.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES) }; } function isExcludedTestDirectoryName(name) { return TEST_DIRECTORY_NAMES.has(name); } function isExcludedTestFileName(name) { return TEST_FILE_NAME_PATTERN.test(name); } function pathContainsNodeModulesSegment(relativePath) { return relativePath.split(/[\\/]+/u).includes("node_modules"); } async function walkDirWithLimit(rootDir, dirPath, candidateLimit, excludeTestFiles, includeHiddenDirectories, includeNestedNodeModulesTestFiles, includeNodeModules) { const files = []; const stack = [dirPath]; while (stack.length > 0 && files.length < candidateLimit) { const currentDir = stack.pop(); if (!currentDir) break; const entries = await readDirEntriesWithCache(currentDir); for (const entry of entries) { if (files.length >= candidateLimit) break; if (!includeHiddenDirectories && entry.name.startsWith(".") || !includeNodeModules && entry.name === "node_modules") continue; const fullPath = path.join(currentDir, entry.name); const isExcludedTestPath = entry.kind === "dir" ? isExcludedTestDirectoryName(entry.name) : isExcludedTestFileName(entry.name); if (excludeTestFiles && isExcludedTestPath && !(includeNestedNodeModulesTestFiles && pathContainsNodeModulesSegment(path.relative(rootDir, fullPath)))) continue; if (entry.kind === "dir") stack.push(fullPath); else if (entry.kind === "file" && isScannable(entry.name)) files.push(fullPath); } } return { files, truncated: files.length >= candidateLimit }; } async function readDirEntriesWithCache(dirPath) { let st; try { st = await fs.stat(dirPath); } catch (err) { if (hasErrnoCode(err, "ENOENT")) return []; throw err; } if (!st?.isDirectory()) return []; const cached = DIR_ENTRY_CACHE.get(dirPath); if (cached && cached.mtimeMs === st.mtimeMs) return cached.entries; const dirents = await fs.readdir(dirPath, { withFileTypes: true }); const entries = []; for (const entry of dirents) if (entry.isDirectory()) entries.push({ name: entry.name, kind: "dir" }); else if (entry.isFile()) entries.push({ name: entry.name, kind: "file" }); setCachedDirEntries(dirPath, { mtimeMs: st.mtimeMs, entries }); return entries; } async function resolveForcedFiles(params) { if (params.includeFiles.length === 0) return []; const seen = /* @__PURE__ */ new Set(); const out = []; 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; try { st = await fs.stat(includePath); } catch (err) { if (hasErrnoCode(err, "ENOENT")) continue; throw err; } if (!st?.isFile()) continue; out.push(includePath); seen.add(includePath); } return out; } async function collectScannableFiles(dirPath, opts) { const forcedFiles = await resolveForcedFiles({ rootDir: dirPath, includeFiles: opts.includeFiles }); if (opts.onlyIncludeFiles) return { files: forcedFiles.slice(0, opts.maxFiles), truncated: forcedFiles.length > opts.maxFiles }; if (forcedFiles.length > opts.maxFiles) return { files: forcedFiles.slice(0, opts.maxFiles), truncated: true }; const walked = await walkDirWithLimit(dirPath, dirPath, opts.maxFiles + 1, opts.excludeTestFiles, opts.includeHiddenDirectories, opts.includeNestedNodeModulesTestFiles, opts.includeNodeModules); const seen = new Set(forcedFiles.map((f) => path.resolve(f))); const out = [...forcedFiles]; for (const walkedFile of walked.files) { const resolved = path.resolve(walkedFile); if (seen.has(resolved)) continue; if (out.length >= opts.maxFiles) return { files: out.slice(0, opts.maxFiles), truncated: true }; out.push(walkedFile); seen.add(resolved); } return { files: out, truncated: false }; } async function scanFileWithCache(params) { const { filePath, maxFileBytes } = params; let st; try { st = await fs.stat(filePath); } catch (err) { if (hasErrnoCode(err, "ENOENT")) return { scanned: false, findings: [] }; throw err; } if (!st?.isFile()) return { scanned: false, findings: [] }; const cached = getCachedFileScanResult({ filePath, size: st.size, mtimeMs: st.mtimeMs, maxFileBytes }); if (cached) return { scanned: cached.scanned, findings: cached.findings }; if (st.size > maxFileBytes) { setCachedFileScanResult(filePath, { size: st.size, mtimeMs: st.mtimeMs, maxFileBytes, scanned: false, findings: [] }); return { scanned: false, findings: [] }; } let source; try { source = await fs.readFile(filePath, "utf-8"); } catch (err) { if (hasErrnoCode(err, "ENOENT")) return { scanned: false, findings: [] }; throw err; } const findings = scanSource(source, filePath); setCachedFileScanResult(filePath, { size: st.size, mtimeMs: st.mtimeMs, maxFileBytes, scanned: true, findings }); return { scanned: true, findings }; } async function scanDirectory(dirPath, opts) { const scanOptions = normalizeScanOptions(opts); const { files } = await collectScannableFiles(dirPath, scanOptions); const allFindings = []; for (const file of files) { const scanResult = await scanFileWithCache({ filePath: file, maxFileBytes: scanOptions.maxFileBytes }); if (!scanResult.scanned) continue; allFindings.push(...scanResult.findings); } return allFindings; } async function scanDirectoryWithSummary(dirPath, opts) { const scanOptions = normalizeScanOptions(opts); const { files, truncated } = await collectScannableFiles(dirPath, scanOptions); const allFindings = []; let scannedFiles = 0; let critical = 0; let warn = 0; let info = 0; for (const file of files) { const scanResult = await scanFileWithCache({ filePath: file, maxFileBytes: scanOptions.maxFileBytes }); if (!scanResult.scanned) continue; scannedFiles += 1; for (const finding of scanResult.findings) { allFindings.push(finding); if (finding.severity === "critical") critical += 1; else if (finding.severity === "warn") warn += 1; else info += 1; } } return { scannedFiles, critical, warn, info, truncated, findings: allFindings }; } //#endregion export { scanSkillContent as a, scanDirectoryWithSummary as i, isScannable as n, scanSource as o, scanDirectory as r, clearSkillScanCacheForTest as t };