UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

255 lines (253 loc) 7.68 kB
import path from "node:path"; import fs from "node:fs/promises"; //#region src/security/skill-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; function isScannable(filePath) { return SCANNABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } function isErrno(err, code) { if (!err || typeof err !== "object") return false; if (!("code" in err)) return false; return err.code === code; } 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 SOURCE_RULES = [ { 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 } ]; function truncateEvidence(evidence, maxLen = 120) { if (evidence.length <= maxLen) return evidence; return `${evidence.slice(0, maxLen)}…`; } function scanSource(source, filePath) { const findings = []; const lines = source.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 === "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; } } const matchedSourceRules = /* @__PURE__ */ new Set(); for (const rule of SOURCE_RULES) { 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; 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; } 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; } function normalizeScanOptions(opts) { 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, candidatePath) { 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, maxFiles) { const files = []; const stack = [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; 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) { 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 = 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, opts) { 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, maxFileBytes) { let st = 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; } } async function scanDirectoryWithSummary(dirPath, opts) { const scanOptions = normalizeScanOptions(opts); const files = await collectScannableFiles(dirPath, scanOptions); const allFindings = []; 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 }; } //#endregion export { scanDirectoryWithSummary as t };