Files
dsh_ai1net_server/归档/试验与样例/poc-dsh-local/patch-b4-b2.py
T

191 lines
10 KiB
Python
Raw Normal View History

#!/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 <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 已写入')