/** Deterministic, browser-safe skill scanner inspired by skill-scanner's two-phase architecture. */ import { LIMITS, safeRelativePath } from './limits.ts' import type { Severity, SkillFile, SkillFinding, SkillScanReport } from './types.ts' export const SCANNER_VERSION = '0.1.0' export const RULESET_VERSION = '2026-08-20.1' interface TextFile { readonly path: string readonly text: string } export type ReviewScope = 'smart' | 'skill' | 'findings' | 'selected' | 'all' export interface ReviewExcerptOptions { readonly scope: ReviewScope readonly findings: readonly SkillFinding[] readonly selectedPaths?: readonly string[] readonly maxChars: number } interface Rule { readonly id: string readonly category: string readonly severity: Severity readonly title: string readonly description: string readonly remediation: string readonly pattern: RegExp readonly secret?: boolean } const RULES: readonly Rule[] = [ { id: 'prompt.ignore-instructions', category: 'prompt-injection', severity: 'high', title: '疑似覆盖上级指令', description: '内容要求忽略、覆盖或绕过此前指令。', remediation: '删除覆盖上级指令的内容,并明确 Skill 仅描述自身任务。', pattern: /(?:ignore|disregard|forget|override)\s+(?:all\s+)?(?:previous|prior|above|system|developer)\s+(?:instructions?|prompts?)|忽略(?:之前|此前|以上|系统|开发者)(?:的)?(?:指令|提示)/gi }, { id: 'prompt.fake-role', category: 'prompt-injection', severity: 'high', title: '疑似伪造高权限角色', description: '内容尝试伪造 system/developer 消息或切换到无约束模式。', remediation: '移除角色伪造和越权模式切换指令。', pattern: /(?:^|\n)\s*(?:system|developer)\s*:\s*|(?:jailbreak|developer mode|DAN mode|越狱模式|开发者模式)/gim }, { id: 'prompt.secret-exfil', category: 'prompt-injection', severity: 'critical', title: '诱导泄露系统提示或凭据', description: '内容要求读取或输出系统提示、密钥、令牌或环境凭据。', remediation: '删除秘密读取/输出要求;使用明确、最小化的输入参数。', pattern: /(?:reveal|print|dump|exfiltrat\w*|send)\s+(?:the\s+)?(?:system prompt|developer message|api key|token|credentials?|secrets?)|(?:输出|泄露|发送|读取)(?:系统提示|开发者消息|密钥|令牌|凭据|秘密)/gi }, { id: 'command.download-execute', category: 'unsafe-command', severity: 'critical', title: '下载后直接执行', description: '检测到把远程内容直接交给 shell 或解释器执行。', remediation: '先固定来源与哈希,下载到隔离位置,审查后再执行。', pattern: /(?:curl|wget)\b[^\n|;&]{0,400}(?:\||&&)\s*(?:sh|bash|zsh|python|node|pwsh|powershell)\b/gi }, { id: 'command.dynamic-code', category: 'unsafe-command', severity: 'high', title: '动态代码执行', description: '检测到 eval、exec、Function 构造器或相似动态执行。', remediation: '使用静态解析和显式允许列表替代动态执行。', pattern: /\b(?:eval|exec)\s*\(|new\s+Function\s*\(|child_process\.(?:exec|spawn)\s*\(/gi }, { id: 'command.destructive', category: 'unsafe-command', severity: 'critical', title: '破坏性系统命令', description: '检测到递归删除、磁盘格式化或危险权限修改。', remediation: '移除破坏性命令,限制操作目标并增加人工确认。', pattern: /\brm\s+-[a-z]*r[a-z]*f\b|\bmkfs(?:\.|\s)|\bformat\s+[a-z]:|\bchmod\s+(?:-R\s+)?777\b/gi }, { id: 'credential.private-key', category: 'hardcoded-secret', severity: 'critical', title: '疑似私钥内容', description: '文件包含私钥头。', remediation: '立即移除并轮换对应密钥;仅通过凭据服务引用。', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g, secret: true }, { id: 'credential.api-token', category: 'hardcoded-secret', severity: 'high', title: '疑似硬编码令牌', description: '检测到常见 API 密钥或访问令牌格式。', remediation: '移除并轮换令牌,改用 DSH 凭据引用或环境注入。', pattern: /\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16})\b/g, secret: true }, { id: 'credential.sensitive-path', category: 'credential-access', severity: 'high', title: '读取敏感凭据路径', description: '内容访问 .env、SSH、云厂商或系统凭据目录。', remediation: '删除通配凭据读取;声明并限制唯一必要的凭据引用。', pattern: /(?:~\/\.ssh|\.aws\/credentials|\.config\/gcloud|\.azure|\/etc\/shadow|(?:^|[/'" ])\.env(?:\b|[/'"]))/gim }, { id: 'network.exfiltration', category: 'data-exfiltration', severity: 'high', title: '疑似数据外传通道', description: '检测到 webhook、隧道、反向 shell 或向远端发送本地数据的模式。', remediation: '删除未声明的网络发送,固定允许的域名并展示发送内容。', pattern: /(?:webhook\.site|requestbin|ngrok|localtunnel|reverse shell|bash\s+-i\s+>&|nc\s+-e|Invoke-WebRequest\b[^\n]{0,300}-Body)/gi }, { id: 'obfuscation.encoded-execution', category: 'obfuscation', severity: 'high', title: '编码载荷或解码后执行', description: '内容包含解码后执行、长 Base64 载荷或 PowerShell 编码命令。', remediation: '改为可审查的明文逻辑;禁止解码后直接执行。', pattern: /(?:base64\s+(?:--decode|-d)|frombase64string|encodedcommand|atob\s*\()[^\n]{0,240}|[a-z0-9+/]{240,}={0,2}/gi }, { id: 'supply.remote-dependency', category: 'supply-chain', severity: 'medium', title: '未固定的远程依赖', description: '检测到 latest、主分支 URL 或未固定版本的安装方式。', remediation: '固定精确版本/提交和完整性哈希,并记录来源。', pattern: /(?:npm|pnpm|yarn|pip|uv)\s+(?:add|install)\s+[^\n]*(?:@latest|\bgit\+|github\.com\/[^\s]+\/(?:main|master)(?:\b|\/))|(?:npm|pnpm)\s+install\s+(?:--global\s+)?[a-z0-9@/_-]+\s*(?:$|\n)/gim }, { id: 'tool.overbroad-permission', category: 'overbroad-permission', severity: 'medium', title: '疑似申请过宽工具权限', description: '内容请求不受限制的文件、shell 或网络访问。', remediation: '改成最小权限、明确目录和域名的工具声明。', pattern: /(?:full|unrestricted|unlimited)\s+(?:filesystem|shell|network|tool)\s+access|(?:任意|无限制|全部)(?:文件系统|shell|网络|工具)(?:访问|权限)/gi }, ] const MACOS_METADATA = /(?:^|\/)\.DS_Store$/i const REVIEW_DENIED = /(?:^|\/)(?:\.env(?:\.[^/]+)?|id_(?:rsa|ed25519)|(?:credentials?|secrets?)(?:\.[^/]+)?)(?:$|\/)/i const REVIEW_PREFERRED = new RegExp( String.raw`(?:^|/)(?:SKILL\.md|README(?:\.[a-z]+)?|[^/]+\.(?:md|txt|json|ya?ml|toml|js|mjs|cjs|ts|tsx|py|sh|bash|zsh|ps1))$`, 'i', ) function commonRoot(paths: readonly string[]): string | undefined { if (paths.length === 0) return undefined const first = paths[0]?.split('/')[0] if (first === undefined || paths.some(path => !path.includes('/') || path.split('/')[0] !== first)) return undefined return first } function rootRelative(path: string, root: string | undefined): string { return root === undefined ? path : path.slice(root.length + 1) } function isLikelyText(bytes: Uint8Array): boolean { const sample = bytes.subarray(0, Math.min(bytes.length, 4096)) let controls = 0 for (const value of sample) { if (value === 0) return false if (value < 9 || (value > 13 && value < 32)) controls++ } return sample.length === 0 || controls / sample.length < 0.02 } function lineNumber(text: string, index: number): number { let line = 1 for (let offset = 0; offset < index; offset++) if (text.charCodeAt(offset) === 10) line++ return line } function evidence(value: string, secret: boolean): string { if (secret) return `[已遮罩,匹配长度 ${value.length}]` return value.replace(/\s+/g, ' ').slice(0, 180) } function finding(rule: Rule, file: TextFile, match: RegExpExecArray): SkillFinding { return { id: `${rule.id}:${file.path}:${match.index}`, ruleId: rule.id, source: 'builtin', category: rule.category, severity: rule.severity, title: rule.title, description: rule.description, path: file.path, line: lineNumber(file.text, match.index), evidence: evidence(match[0], rule.secret === true), remediation: rule.remediation, } } function statusFor(findings: readonly SkillFinding[], complete: boolean): SkillScanReport['status'] { if (!complete) return 'inconclusive' if (findings.some(item => item.severity === 'critical' || item.severity === 'high')) return 'high-risk' if (findings.some(item => item.severity === 'medium' || item.severity === 'low')) return 'needs-review' return 'no-known-risk' } /** Scan a normalized in-memory skill without executing any of its content. */ export function scanSkill(artifactName: string, input: readonly SkillFile[]): SkillScanReport { const notes: string[] = [] const findings: SkillFinding[] = [] const seen = new Set() const seenFolded = new Set() const files: SkillFile[] = [] let totalBytes = 0 let complete = true for (const candidate of input) { const path = safeRelativePath(candidate.path) if (MACOS_METADATA.test(path)) { notes.push(`已忽略 macOS 元数据文件:${path}`) continue } const folded = path.toLocaleLowerCase('en-US') if (seen.has(path) || seenFolded.has(folded)) throw new Error(`重复或大小写冲突的路径:${path}`) seen.add(path) seenFolded.add(folded) if (candidate.bytes.byteLength > LIMITS.maxFileBytes) { complete = false notes.push(`${path} 超过单文件限制,未分析内容。`) continue } totalBytes += candidate.bytes.byteLength if (totalBytes > LIMITS.maxTotalBytes) throw new Error('Skill 总大小超过 20 MiB 限制。') files.push({ path, bytes: candidate.bytes }) } if (files.length > LIMITS.maxFiles) throw new Error(`文件数超过 ${LIMITS.maxFiles} 个限制。`) const textFiles: TextFile[] = [] let binaries = 0 for (const file of files) { if (!isLikelyText(file.bytes)) { binaries++ complete = false notes.push(`${file.path} 是二进制或不可可靠解码,未做内容检查。`) continue } let text = new TextDecoder('utf-8', { fatal: false }).decode(file.bytes) if (text.length > LIMITS.maxTextCharsPerFile) { complete = false notes.push(`${file.path} 文本过长,仅检查前 ${LIMITS.maxTextCharsPerFile} 个字符。`) text = text.slice(0, LIMITS.maxTextCharsPerFile) } textFiles.push({ path: file.path, text }) } const root = commonRoot(files.map(file => file.path)) const skillMd = textFiles.find(file => /^SKILL\.md$/i.test(rootRelative(file.path, root))) if (skillMd === undefined) { findings.push({ id: 'structure.missing-skill-md', ruleId: 'structure.missing-skill-md', source: 'builtin', category: 'structure', severity: 'medium', title: '缺少 SKILL.md', description: '未找到标准 Skill 入口文件。', remediation: '在 Skill 根目录提供包含 frontmatter 的 SKILL.md。' }) } else if (!skillMd.text.startsWith('---\n') && !skillMd.text.startsWith('---\r\n')) { findings.push({ id: 'structure.missing-frontmatter', ruleId: 'structure.missing-frontmatter', source: 'builtin', category: 'structure', severity: 'low', title: 'SKILL.md 缺少 frontmatter', description: '入口文件没有以 YAML frontmatter 开始。', path: skillMd.path, line: 1, remediation: '补充 name、description 等基础元数据。' }) } for (const file of textFiles) { if (/(?:^|\/)\.(?:env|git-credentials|npmrc|pypirc)$/i.test(file.path)) { findings.push({ id: `structure.sensitive-file:${file.path}`, ruleId: 'structure.sensitive-file', source: 'builtin', category: 'hardcoded-secret', severity: 'high', title: 'Skill 包含敏感配置文件', description: '包内出现常见凭据或私有配置文件。', path: file.path, remediation: '从 Skill 中删除该文件并轮换其中可能存在的凭据。' }) } for (const rule of RULES) { rule.pattern.lastIndex = 0 let count = 0 for (let match = rule.pattern.exec(file.text); match !== null && count < 8; match = rule.pattern.exec(file.text)) { findings.push(finding(rule, file, match)) count++ if (match[0].length === 0) rule.pattern.lastIndex++ } } } findings.sort((a, b) => { const order: Record = { critical: 0, high: 1, medium: 2, low: 3, info: 4 } return order[a.severity] - order[b.severity] || (a.path ?? '').localeCompare(b.path ?? '') || (a.line ?? 0) - (b.line ?? 0) }) return { scannerVersion: SCANNER_VERSION, rulesetVersion: RULESET_VERSION, artifactName, status: statusFor(findings, complete), complete, inventory: { files: files.length, totalBytes, textFiles: textFiles.length, binaryFiles: binaries, skippedFiles: input.length - files.length, }, findings, notes, } } function redactedText(file: SkillFile): string | undefined { if (REVIEW_DENIED.test(file.path) || !REVIEW_PREFERRED.test(file.path) || !isLikelyText(file.bytes)) return undefined return new TextDecoder().decode(file.bytes) .replace(/-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g, '[PRIVATE KEY REDACTED]') .replace(/\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16})\b/g, '[TOKEN REDACTED]') } /** List text files eligible for optional model review after secret-name filtering. */ export function reviewableFilePaths(input: readonly SkillFile[]): readonly string[] { return input.filter(file => !MACOS_METADATA.test(file.path) && redactedText(file) !== undefined).map(file => file.path) } function findingWindow(text: string, lines: readonly number[]): string { const source = text.split(/\r?\n/) const included = new Set() for (const line of lines) { const center = Math.max(0, line - 1) for (let index = Math.max(0, center - 20); index <= Math.min(source.length - 1, center + 20); index++) included.add(index) } return [...included].sort((a, b) => a - b).map(index => `${index + 1}: ${source[index] ?? ''}`).join('\n') } /** Select bounded, redacted text for optional model review under a user-selected scope. */ export function llmExcerpts(input: readonly SkillFile[], options?: ReviewExcerptOptions): readonly { path: string; text: string }[] { const resolved = options ?? { scope: 'all' as const, findings: [], maxChars: LIMITS.maxLlmExcerptChars } let remaining = Math.min(Math.max(resolved.maxChars, 1), LIMITS.maxLlmExcerptChars) const result: { path: string; text: string }[] = [] const root = commonRoot(input.filter(file => !MACOS_METADATA.test(file.path)).map(file => file.path)) const selected = new Set(resolved.selectedPaths ?? []) for (const file of input) { if (MACOS_METADATA.test(file.path)) continue const redacted = redactedText(file) if (redacted === undefined) continue const isSkill = /^SKILL\.md$/i.test(rootRelative(file.path, root)) const fileFindings = resolved.findings.filter(item => item.path === file.path && item.line !== undefined) let candidate: string | undefined if (resolved.scope === 'skill') candidate = isSkill ? redacted : undefined else if (resolved.scope === 'findings') candidate = fileFindings.length === 0 ? undefined : findingWindow(redacted, fileFindings.map(item => item.line ?? 1)) else if (resolved.scope === 'selected') candidate = selected.has(file.path) ? redacted : undefined else if (resolved.scope === 'smart') { if (fileFindings.length > 0) candidate = findingWindow(redacted, fileFindings.map(item => item.line ?? 1)) else if (isSkill) candidate = redacted else if (/\.(?:js|mjs|cjs|ts|tsx|py|sh|bash|zsh|ps1)$/i.test(file.path)) candidate = redacted.slice(0, 2_000) } else candidate = redacted if (candidate === undefined || candidate.length === 0) continue const text = candidate.slice(0, Math.min(remaining, LIMITS.maxLlmExcerptCharsPerFile)) remaining -= text.length result.push({ path: file.path, text }) if (remaining === 0) return result } return result }