/** * Security scanner for skill content (SKILL.md and embedded scripts). * Detects dangerous patterns and returns structured findings. */ import { readdirSync, lstatSync, readFileSync } from 'fs'; import { join, relative } from 'path'; export interface ScanFinding { severity: 'medium' | 'high'; pattern: string; // pattern category name match: string; // the matched text (truncated to 100 chars) line: number; // 1-based line number file?: string; // relative file path within a skill directory } interface PatternDef { severity: 'medium' | 'high'; name: string; regex: RegExp; } const PATTERNS: PatternDef[] = [ // --- Medium severity --- { severity: 'medium', name: 'external-url', regex: /https?:\/\/[^\s)'"]+/g, }, { severity: 'medium', name: 'network-cmd-direct', regex: /\b(?:curl|wget|nc|ncat|netcat)\b/g, }, { severity: 'medium', name: 'network-cmd-indirect', regex: /\b(?:urllib|http\.client|require\s*\(\s*['"](?:http|https|net)['"]\s*\)|fetch\s*\()/g, }, { severity: 'medium', name: 'exfil-tool', regex: /\b(?:WebFetch|DownloadFile)\b/g, }, // --- High severity --- { severity: 'high', name: 'other-user-resource', regex: /\b(?:ReadUserMemory|UpdateUserMemory)\b/g, }, { severity: 'high', name: 'path-traversal', regex: /\.\.\/|\/home\//g, }, { severity: 'high', name: 'broad-collection', regex: /(?:全ファイル|秘密情報|all\s+files|secret|credential|password|private\s+key)/gi, }, { severity: 'high', name: 'prompt-injection', regex: /\b(?:ignore\s+previous|disregard|system\s+prompt|override\s+instructions|forget\s+(?:your|all|the)\s+(?:instructions|rules))\b/gi, }, ]; function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) : s; } /** * Scan skill content line-by-line against known dangerous patterns. */ export function scanSkillContent(content: string): ScanFinding[] { const findings: ScanFinding[] = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; for (const pat of PATTERNS) { pat.regex.lastIndex = 0; let m: RegExpExecArray | null; while ((m = pat.regex.exec(line)) !== null) { findings.push({ severity: pat.severity, pattern: pat.name, match: truncate(m[0], 100), line: i + 1, }); } } } return findings; } /** * Return the highest severity across all findings. */ export function maxSeverity(findings: ScanFinding[]): 'high' | 'medium' | 'none' { if (findings.some(f => f.severity === 'high')) return 'high'; if (findings.some(f => f.severity === 'medium')) return 'medium'; return 'none'; } export interface ScanDirectoryOptions { maxDepth?: number; // default: 3 maxFiles?: number; // default: 100 } const DEFAULT_MAX_DEPTH = 3; const DEFAULT_MAX_FILES = 100; const MAX_FILE_SIZE = 256 * 1024; // 256 KB /** * Check if a buffer looks like binary content (contains null bytes in first 512 bytes). */ function isBinary(buf: Buffer): boolean { const check = Math.min(buf.length, 512); for (let i = 0; i < check; i++) { if (buf[i] === 0) return true; } return false; } /** * Scan all text files in a skill directory recursively. * Skips symlinks, binary files, files > 256 KB, and respects depth/file count limits. */ export function scanSkillDirectory( dirPath: string, options?: ScanDirectoryOptions, ): ScanFinding[] { const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH; const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; const findings: ScanFinding[] = []; let fileCount = 0; function walk(currentDir: string, depth: number): void { if (depth > maxDepth) return; if (fileCount >= maxFiles) return; let entries: string[]; try { entries = readdirSync(currentDir); } catch { return; // unreadable directory — skip } for (const entry of entries) { if (fileCount >= maxFiles) return; const fullPath = join(currentDir, entry); let stat; try { stat = lstatSync(fullPath); } catch { continue; // unreadable entry — skip } // Skip symlinks if (stat.isSymbolicLink()) continue; if (stat.isDirectory()) { walk(fullPath, depth + 1); continue; } if (!stat.isFile()) continue; // Skip files larger than 256 KB if (stat.size > MAX_FILE_SIZE) continue; let buf: Buffer; try { buf = readFileSync(fullPath); } catch { continue; // unreadable file — skip } // Skip binary files if (buf.length > 0 && isBinary(buf)) continue; fileCount++; const content = buf.toString('utf-8'); const fileFindings = scanSkillContent(content); const relPath = relative(dirPath, fullPath); for (const finding of fileFindings) { findings.push({ ...finding, file: relPath }); } } } walk(dirPath, 0); return findings; }