- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
192 lines
10 KiB
Python
192 lines
10 KiB
Python
#!/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 已写入')
|