#!/usr/bin/env python3 # 档案 19 §C6(B4:安全扫描分级扩展)+ 档案 18 v3 收尾(B2:编排器自愈补齐 picker) # 用法: python3 patch-b4-b2.py /opt/dshs import io, os, sys root = sys.argv[1] if len(sys.argv) > 1 else '.' def rd(p): return io.open(os.path.join(root,p), encoding='utf-8').read() def wr(p,s): io.open(os.path.join(root,p),'w',encoding='utf-8',newline='').write(s) def sub(p, old, new, cnt=1): s = rd(p); assert old in s, f'anchor missing in {p}: {old[:80]}' wr(p, s.replace(old,new,cnt)) # ───────────────────────── B4:security-scan.ts 分级扩展 ───────────────────────── p = 'src/web/security-scan.ts' s = rd(p) s = s.replace("""/** * Shared upload security scan (安全检测) for skill and business-plugin uploads. * Heuristic scan over text files only — a hit means an *obviously* malicious or * privilege-escalating pattern, so the upload is rejected rather than admitted. * @module dshs/web/security-scan */""", """/** * Shared upload security scan (安全检测) for skill and business-plugin uploads. * * 分级(档案 19 §C6,2026-09-11): * · **P0(阻断)** —— 明显恶意/提权模式:命中即拒绝上传(HTTP 400),与历史行为一致; * · **P1(告警)** —— 可疑但常见的模式(动态执行、外联、敏感 env、长 base64、隐藏文件/`.git`): * **不阻断**,作为 findings 返回并写日志,供管理员在上传时人工复核。 * 之前只有"命中即 400",缺少分级与整改提示;现在 `scanDir` 返回 P1 findings(调用方可忽略)。 * @module dshs/web/security-scan */""", 1) s = s.replace("""const SCAN_TEXT_EXT = new Set([ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.sh', '.bash', '.py', '.json', '.md', '.txt', '.yml', '.yaml', '.html', '.css', ])""", """const SCAN_TEXT_EXT = new Set([ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.sh', '.bash', '.zsh', '.py', '.json', '.md', '.txt', '.yml', '.yaml', '.html', '.css', '.toml', '.ini', '.conf', '.env', '.cfg', // 2026-09-11 扩展:非 JS 生态的常见脚本/配置(上传包里同样可能藏恶意指令) '.rs', '.go', '.java', '.php', '.rb', '.pl', '.lua', '.sql', '.ps1', '.psm1', '.bat', '.cmd', ]) /** 扫描体积上限(旧值 2MB 会漏掉较大脚本;提到 8MB,仍跳过二进制/超大文件)。 */ const SCAN_MAX_BYTES = 8 * 1024 * 1024""", 1) # 原 7 条 → BLOCK(P0),并补充明显恶意的模式 s = s.replace("const DANGEROUS_PATTERNS: Array<{ re: RegExp; why: string }> = [", "/** P0:明显恶意/提权 → 直接阻断上传。 */\nconst BLOCK_PATTERNS: Array<{ re: RegExp; why: string }> = [", 1) s = s.replace(""" { re: /\\.credentials\\.yaml/, why: '读取实例会话密钥' }, ]""", """ { re: /\\.credentials\\.yaml/, why: '读取实例会话密钥' }, // 2026-09-11 新增(档案 17 §S/M 对齐):仍是"明显恶意"档,故归 P0 { re: /\\bnc\\s+-[a-z]*e[a-z]*\\s+\\S+\\s+\\d+/, why: '反弹 shell(nc -e)' }, { re: /\\/dev\\/tcp\\/\\d{1,3}(\\.\\d{1,3}){3}\\/\\d+/, why: '反弹 shell(/dev/tcp)' }, { re: /(?:base64\\s+-d|b64decode|atob)\\s*[\\s\\S]{0,40}?\\|\\s*(?:sh|bash)\\b/, why: 'base64 解码后直接执行' }, { re: /\\bchmod\\s+(?:\\+s|4[0-7]{3}|6[0-7]{3})\\b[^\\n]{0,40}(?:\\/usr\\/bin|\\/bin)\\//, why: '尝试为系统二进制加 setuid/特权位' }, { re: /:\\s*\\(\\s*\\)\\s*\\{\\s*:\\s*\\|\\s*:/, why: 'fork 炸弹' }, ] /** * P1:可疑但常见 → **不阻断**,作为 findings 上报(管理员人工复核)。 * 说明书见档案 19 §C6 与档案 17 §S/M。 */ const WARN_RULES: Array<{ id: string; re: RegExp; why: string }> = [ { id: 'dynamic-exec', re: /\\b(?:child_process|execSync|execFileSync|spawnSync|require\\('child_process'\\))/, why: '动态执行子进程(需确认目标命令可信)' }, { id: 'vm-eval', re: /\\b(?:new\\s+Function\\s*\\(|require\\('vm'\\)|from 'node:vm'|eval\\s*\\()/, why: '动态求值 vm/eval/new Function' }, { id: 'sensitive-env', re: /process\\.env\\s*[.\\[]\\s*['"]?(?:DEEPSEEK_API_KEY|DSH_[A-Z_]+|[A-Z_]*TOKEN|[A-Z_]*SECRET|[A-Z_]*KEY)/, why: '读取敏感环境变量' }, { id: 'long-base64', re: /[A-Za-z0-9+/]{600,}={0,2}/, why: '超长 base64 载荷(可能是混淆/内嵌二进制)' }, { id: 'network-egress', re: /https?:\\/\\/(?!localhost|127\\.0\\.0\\.1)[a-z0-9.-]+\\.[a-z]{2,}/i, why: '外部网络访问(需确认域名可信;配合出网护栏)' }, { id: 'hidden-or-git', re: /(?:\\.git\\/|(?:^|\\/)\\.[a-z][a-z0-9_-]*\\/)/i, why: '包含隐藏目录/.git(可能携带仓库元数据或绕过审查)' }, ] /** 一条扫描发现(P1 告警)。 */ export interface ScanFinding { rule: string; why: string; file: string }""", 1) # scanDir:跳过二进制、用新上限、收集 P1 findings 并返回 s = s.replace("""/** Recursively scan text files under `root` for dangerous patterns. */ export function scanDir(root: string, rel = ''): void {""", """/** * Recursively scan text files under `root`. * P0 命中 → 抛 400(阻断上传);P1 命中 → 收集并**返回**(调用方可记录/展示,不影响上传)。 */ export function scanDir(root: string, rel = '', findings: ScanFinding[] = []): ScanFinding[] {""", 1) s = s.replace(""" if (st.isDirectory()) { scanDir(abs, relPath) continue }""", """ if (st.isDirectory()) { scanDir(abs, relPath, findings) continue }""", 1) s = s.replace(""" const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '' if (!SCAN_TEXT_EXT.has(ext)) continue if (st.size > 2 * 1024 * 1024) continue // skip huge files (unlikely to be source) let text: string try { text = readFileSync(abs, 'utf8') } catch { continue } for (const p of DANGEROUS_PATTERNS) { if (p.re.test(text)) { throw httpError(400, `安全检测未通过:${p.why}(${relPath})`) } } } }""", """ const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '' const hasShebangCandidate = ext === '' // 无扩展名文件也可能是脚本(按内容判 shebang) if (!SCAN_TEXT_EXT.has(ext) && !hasShebangCandidate) continue if (st.size > SCAN_MAX_BYTES) continue // 跳过超大文件(不太可能是源码) let text: string try { text = readFileSync(abs, 'utf8') } catch { continue } // 二进制探测 + shebang 判定(0x00 视为二进制;无扩展名须含 shebang 才继续) if (text.includes('\\u0000')) continue if (hasShebangCandidate && !/^#!\\s*\\S/.test(text.slice(0, 64))) continue for (const p of BLOCK_PATTERNS) { if (p.re.test(text)) { throw httpError(400, `安全检测未通过(P0 阻断):${p.why}(${relPath})`) } } for (const w of WARN_RULES) { if (w.re.test(text)) { findings.push({ rule: w.id, why: w.why, file: relPath }) } } } return findings }""", 1) wr(p, s) # 调用点:记录并回传 P1 findings(加法式,不改变原有阻断行为) sub('src/web/routes/business-plugins.ts', ' scanDir(unzipDir)', """ const scanFindings = scanDir(unzipDir) if (scanFindings.length > 0) { // P1 告警:不阻断,仅记录(管理员可在响应中看到) request.log.warn({ findings: scanFindings.slice(0, 20), count: scanFindings.length }, 'upload-scan-warnings') }""") sub('src/web/routes/skills.ts', ' scanDir(unzipDir)', """ const scanFindings = scanDir(unzipDir) if (scanFindings.length > 0) { request.log.warn({ findings: scanFindings.slice(0, 20), count: scanFindings.length }, 'upload-scan-warnings') }""") # ───────────────────────── B2:编排器自愈补齐 picker ───────────────────────── p = 'src/supervisor/orchestrator.ts' s = rd(p) assert "import { execFileSync, spawn," in s s = s.replace(""" /** Stop the current main (clean) and respawn it with the same folder/patch. */""", """ /** * 档案 18 v3 收尾(第二层自愈):确保该用户的 profile 具备"受限目录选择器" * (平台段 + 插件包)—— 复用平台脚本 `ensure-workspace-picker.cjs --user-id `(幂等)。 * 异步 fire-and-forget:不阻塞启动;失败只记日志(第一层由 provisioning 钩子兜底)。 * 覆盖场景:profile 被清空/重建、手工删掉平台段、插件被卸载。 */ private ensurePickerProfile(userId: string): void { const script = process.env.DSH_PICKER_ENSURE_SCRIPT ?? join(process.cwd(), 'poc', 'workspace-scoped-picker', 'ensure-workspace-picker.cjs') if (!existsSync(script)) return try { const child = spawn('node', [script, '--user-id', userId], { detached: true, stdio: 'ignore' }) child.on('error', (err) => { process.stderr.write(`[picker-ensure] spawn failed: ${err.message}\\n`) }) child.unref() } catch (err) { process.stderr.write( `[picker-ensure] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\\n`, ) } } /** Stop the current main (clean) and respawn it with the same folder/patch. */""", 1) s = s.replace("import { chmodSync, chownSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'", "import { chmodSync, chownSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'", 1) # 调用点:launch 前 + spawn 后 s = s.replace(""" // 显式启动 = 新一轮:清空崩溃计数(用户重新进入后应拿到完整重试预算,档案 20)。 this.resetCrashState(userId)""", """ // 显式启动 = 新一轮:清空崩溃计数(用户重新进入后应拿到完整重试预算,档案 20)。 this.resetCrashState(userId) // 档案 18 v3 收尾:启动前补齐"受限目录选择器"(幂等;首登 profile 尚未创建时会自行跳过)。 this.ensurePickerProfile(userId)""", 1) s = s.replace(""" if (isMain) await this.seedDefaultWorkspace(userId)""", """ // spawn 成功后异步再补一次:首登时 profile 刚被 dsh 创建,这一次能真正装上(第二层自愈)。 if (isMain) this.ensurePickerProfile(userId)""", 1) wr(p, s) print('B4 + B2 已写入')