Files
dsh_ai1net_server/归档/poc-dsh-local/patch-b4-b2.py
T
admin ce8e6ceed9 chore(工作区): 纳入版本控制基线(回收 411 MB 过程产物)
回收 411 MB(470 M → 58.8 M),全部经回收站,可恢复:
- 待清理/(146.2 M,含 relay 分片 128 M 与 42 项过程目录)
- tmp/(32.4 M,按接续棒命名的过程临时区)
- .workbuddy/tmp/(39.5 M)
- 4 份 workbuddy.db 冗余副本(101 M,09-23 事故的坏副本 / 抢救产物)
- tmp/im16/gw/centrifugo 二进制(63.9 M,可重下)+ 缓存残留

入库范围:常驻规则(CODEBUDDY.md / README.md / state.py)、在途接续入口与
接续包、docs/、交付物/、交接单/、归档/、scripts/、.codebuddy/、
.workbuddy/memory/;共 398 件,其中 >60 KB 的 26 件全为文档。

排除(.gitignore):tmp/、待清理/、运行态日志与缓存、*.db 与 DB 备份整目录、
打包二进制(*.tar.gz / *.tgz)、记忆修复前备份。
2026-09-24 07:51:03 +08:00

192 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 已写入')