fix(aibox/skills): Batch M 波次2 路径边界收口(S2/S3/S6/S9/S10/S11/S12/S13)
- gitclient: delete 限工作区内 .git 仓库(S2);分支名白名单防选项注入(S3) - camera/screenshot: --filename 拒绝分隔符/绝对路径/父目录引用(S9/S10) - facerec: person_name 白名单(兼容中文)接入 add/remove/get-info(S11) - vision: 图片魔数白名单校验,data URL MIME 按实测类型(S12) - sftp-transfer: listdir 条目名双向过滤防恶意服务端目录逃逸(S6) - test-auto-appimage: exec_name 白名单防 sed 替换注入(S13) 测试 493 passed / 4 failed(既有平台环境项)/ 1 skipped,零回归
This commit is contained in:
1 parent
5dc5ec9af2
commit
9064750f33
8 files changed
+133
-5
No files matched your search
@@ -18,6 +18,16 @@ def ensure_camera_dir():
|
|||||||
return CAMERA_DIR
|
return CAMERA_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def validate_filename(filename):
|
||||||
|
"""S9(审计 2026-08-20):--filename 仅允许纯文件名——
|
||||||
|
拒绝路径分隔符/父目录引用/绝对路径,防止经 os.path.join 逃逸 CAMERA_DIR"""
|
||||||
|
if not filename:
|
||||||
|
return "文件名不能为空"
|
||||||
|
if filename in (".", "..") or "/" in filename or "\\" in filename or os.path.isabs(filename):
|
||||||
|
return f"非法文件名(不允许路径分隔符或父目录引用): {filename}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def list_cameras(max_test=10):
|
def list_cameras(max_test=10):
|
||||||
"""列出可用的摄像头设备"""
|
"""列出可用的摄像头设备"""
|
||||||
available_cameras = []
|
available_cameras = []
|
||||||
@@ -341,6 +351,10 @@ def main():
|
|||||||
ensure_camera_dir()
|
ensure_camera_dir()
|
||||||
|
|
||||||
if args.filename:
|
if args.filename:
|
||||||
|
filename_error = validate_filename(args.filename)
|
||||||
|
if filename_error:
|
||||||
|
print(f"❌ {filename_error}")
|
||||||
|
sys.exit(1)
|
||||||
save_path = os.path.join(CAMERA_DIR, args.filename)
|
save_path = os.path.join(CAMERA_DIR, args.filename)
|
||||||
else:
|
else:
|
||||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"""人脸检测和识别工具 - 检测图像中的人脸并保存裁剪的人脸图像。"""
|
"""人脸检测和识别工具 - 检测图像中的人脸并保存裁剪的人脸图像。"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
import cv2
|
import cv2
|
||||||
@@ -15,6 +16,16 @@ except ImportError:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# S11(审计 2026-08-20):person_name 白名单——仅允许文字/数字/._-,
|
||||||
|
# 拒绝路径分隔符与纯点名("."/".."),防止经 f"{name}.jpg" 拼接逃逸 known_faces 目录。
|
||||||
|
# \w 在 py3 默认 UNICODE,兼容中文人名(如 "张三")。
|
||||||
|
_PERSON_NAME_RE = re.compile(r"^(?!\.+$)[\w.-]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_person_name(name):
|
||||||
|
return bool(name) and bool(_PERSON_NAME_RE.match(name))
|
||||||
|
|
||||||
|
|
||||||
class FaceDetector:
|
class FaceDetector:
|
||||||
"""人脸检测和识别工具"""
|
"""人脸检测和识别工具"""
|
||||||
|
|
||||||
@@ -198,6 +209,8 @@ class FaceDetector:
|
|||||||
def add_known_face(self, image_path: str, person_name: str) -> str:
|
def add_known_face(self, image_path: str, person_name: str) -> str:
|
||||||
"""添加已知人脸到数据库"""
|
"""添加已知人脸到数据库"""
|
||||||
try:
|
try:
|
||||||
|
if not _valid_person_name(person_name):
|
||||||
|
return f"❌ 非法人名标识: {person_name}(仅允许文字/数字/._-,≤64字符)"
|
||||||
if not os.path.exists(image_path):
|
if not os.path.exists(image_path):
|
||||||
return f"❌ 文件不存在: {image_path}"
|
return f"❌ 文件不存在: {image_path}"
|
||||||
|
|
||||||
@@ -237,6 +250,8 @@ class FaceDetector:
|
|||||||
def remove_known_face(self, person_name: str) -> str:
|
def remove_known_face(self, person_name: str) -> str:
|
||||||
"""从已知人脸库中删除人脸"""
|
"""从已知人脸库中删除人脸"""
|
||||||
try:
|
try:
|
||||||
|
if not _valid_person_name(person_name):
|
||||||
|
return f"❌ 非法人名标识: {person_name}(仅允许文字/数字/._-,≤64字符)"
|
||||||
filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg")
|
filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg")
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
@@ -260,6 +275,8 @@ class FaceDetector:
|
|||||||
def get_face_info(self, person_name: str) -> str:
|
def get_face_info(self, person_name: str) -> str:
|
||||||
"""获取已知人脸信息"""
|
"""获取已知人脸信息"""
|
||||||
try:
|
try:
|
||||||
|
if not _valid_person_name(person_name):
|
||||||
|
return f"❌ 非法人名标识: {person_name}(仅允许文字/数字/._-,≤64字符)"
|
||||||
filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg")
|
filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg")
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import argparse
|
import argparse
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -35,6 +36,13 @@ MAX_CONTENT_PREVIEW = 1000
|
|||||||
MAX_LIST_PREVIEW = 20
|
MAX_LIST_PREVIEW = 20
|
||||||
MAX_STRING_LENGTH = 200
|
MAX_STRING_LENGTH = 200
|
||||||
|
|
||||||
|
# S3:分支/ref 白名单——首字符必须为字母或数字,防止以 '-' 开头的值被 git 解析为选项(选项注入)
|
||||||
|
_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/@+-]{0,127}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_ref(name: str) -> bool:
|
||||||
|
return bool(name) and bool(_REF_RE.match(name))
|
||||||
|
|
||||||
|
|
||||||
# ==================== GitHub工具类 ====================
|
# ==================== GitHub工具类 ====================
|
||||||
|
|
||||||
@@ -165,6 +173,8 @@ class GitHubTool:
|
|||||||
if os.path.exists(target_dir):
|
if os.path.exists(target_dir):
|
||||||
return f"⚠️ 仓库已存在: {target_dir}\n💡 使用 pull 命令拉取更新"
|
return f"⚠️ 仓库已存在: {target_dir}\n💡 使用 pull 命令拉取更新"
|
||||||
|
|
||||||
|
if branch and not _valid_ref(branch):
|
||||||
|
return f"❌ 非法分支名: {branch}"
|
||||||
clone_url = f"https://github.com/{repo}.git"
|
clone_url = f"https://github.com/{repo}.git"
|
||||||
cmd = ["clone"]
|
cmd = ["clone"]
|
||||||
if depth > 0:
|
if depth > 0:
|
||||||
@@ -186,6 +196,8 @@ class GitHubTool:
|
|||||||
br = self._run_git(repo_path, ["branch", "--show-current"])
|
br = self._run_git(repo_path, ["branch", "--show-current"])
|
||||||
branch = br["stdout"] if br["success"] else None
|
branch = br["stdout"] if br["success"] else None
|
||||||
|
|
||||||
|
if branch and not _valid_ref(branch):
|
||||||
|
return f"❌ 非法分支名: {branch}"
|
||||||
result = self._run_git(repo_path, ["pull", "origin", branch] if branch else ["pull"])
|
result = self._run_git(repo_path, ["pull", "origin", branch] if branch else ["pull"])
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
return f"✅ 拉取成功\n {result['stdout'] or '已是最新'}"
|
return f"✅ 拉取成功\n {result['stdout'] or '已是最新'}"
|
||||||
@@ -199,6 +211,8 @@ class GitHubTool:
|
|||||||
br = self._run_git(repo_path, ["branch", "--show-current"])
|
br = self._run_git(repo_path, ["branch", "--show-current"])
|
||||||
branch = br["stdout"] if br["success"] else None
|
branch = br["stdout"] if br["success"] else None
|
||||||
|
|
||||||
|
if branch and not _valid_ref(branch):
|
||||||
|
return f"❌ 非法分支名: {branch}"
|
||||||
cmd = ["push"]
|
cmd = ["push"]
|
||||||
if force:
|
if force:
|
||||||
cmd.append("--force")
|
cmd.append("--force")
|
||||||
@@ -284,9 +298,15 @@ class GitHubTool:
|
|||||||
return "⚠️ 需要确认删除,请添加 --confirm 参数"
|
return "⚠️ 需要确认删除,请添加 --confirm 参数"
|
||||||
if not os.path.exists(repo_path):
|
if not os.path.exists(repo_path):
|
||||||
return f"❌ 路径不存在: {repo_path}"
|
return f"❌ 路径不存在: {repo_path}"
|
||||||
|
# S2:仅允许删除 WORKSPACE 内的 git 仓库,防止任意目录递归删除
|
||||||
|
workspace = os.path.realpath(WORKSPACE_DIR)
|
||||||
|
real_path = os.path.realpath(repo_path)
|
||||||
|
if not real_path.startswith(workspace + os.sep) \
|
||||||
|
or not os.path.isdir(os.path.join(real_path, ".git")):
|
||||||
|
return f"❌ 仅允许删除工作区({workspace})内的 git 仓库目录"
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(repo_path)
|
shutil.rmtree(real_path)
|
||||||
return f"✅ 已删除: {repo_path}"
|
return f"✅ 已删除: {real_path}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"❌ 删除失败: {e}"
|
return f"❌ 删除失败: {e}"
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ def get_timestamp_filename(prefix="screenshot", extension="png"):
|
|||||||
return f"{prefix}_{timestamp}.{extension}"
|
return f"{prefix}_{timestamp}.{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_filename(filename):
|
||||||
|
"""S10(审计 2026-08-20):--filename 仅允许纯文件名——
|
||||||
|
拒绝路径分隔符/父目录引用/绝对路径,防止经 os.path.join 逃逸保存目录"""
|
||||||
|
if filename in (".", "..") or "/" in filename or "\\" in filename or os.path.isabs(filename):
|
||||||
|
return f"非法文件名(不允许路径分隔符或父目录引用): {filename}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_system():
|
def get_system():
|
||||||
"""获取当前操作系统"""
|
"""获取当前操作系统"""
|
||||||
return platform.system()
|
return platform.system()
|
||||||
@@ -154,6 +162,9 @@ def take_screenshot(output_dir=None, filename=None, delay=0):
|
|||||||
os.makedirs(save_dir, exist_ok=True)
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
if filename:
|
if filename:
|
||||||
|
filename_error = validate_filename(filename)
|
||||||
|
if filename_error:
|
||||||
|
return False, filename_error, None
|
||||||
filepath = os.path.join(save_dir, filename)
|
filepath = os.path.join(save_dir, filename)
|
||||||
else:
|
else:
|
||||||
filepath = os.path.join(save_dir, get_timestamp_filename())
|
filepath = os.path.join(save_dir, get_timestamp_filename())
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ def log(verbose, msg):
|
|||||||
print(f"[SFTP] {msg}")
|
print(f"[SFTP] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_member_name(name):
|
||||||
|
"""S6(审计 2026-08-20):目录成员名校验——拒绝路径分隔符与 . / ..,
|
||||||
|
防止恶意 SFTP 服务端构造 listdir 条目(如 ../../etc/cron.d/x)在递归下载时逃逸本地目录"""
|
||||||
|
return name not in ('', '.', '..') and '/' not in name and '\\' not in name
|
||||||
|
|
||||||
|
|
||||||
def get_password(args):
|
def get_password(args):
|
||||||
"""获取密码:环境变量 AIBOX_SFTP_PASS 优先,否则交互式输入(S5:凭据不走命令行)"""
|
"""获取密码:环境变量 AIBOX_SFTP_PASS 优先,否则交互式输入(S5:凭据不走命令行)"""
|
||||||
env_pass = os.environ.get("AIBOX_SFTP_PASS")
|
env_pass = os.environ.get("AIBOX_SFTP_PASS")
|
||||||
@@ -149,6 +155,9 @@ def upload_dir(sftp, local_dir, remote_dir, verbose):
|
|||||||
"""递归上传目录"""
|
"""递归上传目录"""
|
||||||
ensure_remote_dir(sftp, remote_dir, verbose)
|
ensure_remote_dir(sftp, remote_dir, verbose)
|
||||||
for item in os.listdir(local_dir):
|
for item in os.listdir(local_dir):
|
||||||
|
if not safe_member_name(item):
|
||||||
|
print(f" 警告: 跳过非法本地条目名: {item!r}", file=sys.stderr)
|
||||||
|
continue
|
||||||
local_item = os.path.join(local_dir, item)
|
local_item = os.path.join(local_dir, item)
|
||||||
remote_item = os.path.join(remote_dir, item)
|
remote_item = os.path.join(remote_dir, item)
|
||||||
if os.path.isdir(local_item):
|
if os.path.isdir(local_item):
|
||||||
@@ -174,6 +183,9 @@ def download_dir(sftp, remote_dir, local_dir, verbose):
|
|||||||
log(verbose, f" 创建本地目录: {local_dir}")
|
log(verbose, f" 创建本地目录: {local_dir}")
|
||||||
|
|
||||||
for item in sftp.listdir(remote_dir):
|
for item in sftp.listdir(remote_dir):
|
||||||
|
if not safe_member_name(item):
|
||||||
|
print(f" 警告: 跳过非法远端条目名: {item!r}", file=sys.stderr)
|
||||||
|
continue
|
||||||
remote_item = os.path.join(remote_dir, item).replace('\\', '/')
|
remote_item = os.path.join(remote_dir, item).replace('\\', '/')
|
||||||
local_item = os.path.join(local_dir, item)
|
local_item = os.path.join(local_dir, item)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ create_apprun() {
|
|||||||
local app_dir="$1"
|
local app_dir="$1"
|
||||||
local exec_name="$2"
|
local exec_name="$2"
|
||||||
local terminal="$3"
|
local terminal="$3"
|
||||||
|
|
||||||
|
# S13(审计 2026-08-20):exec_name 进入 sed 替换串,含 / & 等字符会注入或破坏
|
||||||
|
# 替换表达式(如 "a/x" 使 s///g 错位、"&" 展开为匹配串)。白名单拦截,拒绝即退出。
|
||||||
|
if [[ ! "$exec_name" =~ ^[A-Za-z0-9._+-]+$ ]]; then
|
||||||
|
echo -e "${RED}错误: 可执行文件名含非法字符(仅允许 A-Za-z0-9._+-): $exec_name${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cat > "$app_dir/AppRun" << 'EOF'
|
cat > "$app_dir/AppRun" << 'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
HERE="$(dirname "$(readlink -f "$0")")"
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|||||||
+27
-2
@@ -23,6 +23,27 @@ def get_api_key():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_image_mime(image_path):
|
||||||
|
"""S12(审计 2026-08-20):按魔数识别真实图片类型——
|
||||||
|
拒绝非图片文件(防任意文件读取外泄),并返回正确 MIME(此前硬编码 image/jpeg)"""
|
||||||
|
try:
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
head = f.read(16)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if head.startswith(b'\xff\xd8\xff'):
|
||||||
|
return 'image/jpeg'
|
||||||
|
if head.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||||
|
return 'image/png'
|
||||||
|
if head[:6] in (b'GIF87a', b'GIF89a'):
|
||||||
|
return 'image/gif'
|
||||||
|
if head.startswith(b'RIFF') and head[8:12] == b'WEBP':
|
||||||
|
return 'image/webp'
|
||||||
|
if head.startswith(b'BM'):
|
||||||
|
return 'image/bmp'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def encode_image_to_base64(image_path):
|
def encode_image_to_base64(image_path):
|
||||||
"""将图片编码为base64"""
|
"""将图片编码为base64"""
|
||||||
with open(image_path, 'rb') as f:
|
with open(image_path, 'rb') as f:
|
||||||
@@ -37,7 +58,11 @@ def analyze_image(image_path, prompt, stream=False):
|
|||||||
|
|
||||||
if not os.path.exists(image_path):
|
if not os.path.exists(image_path):
|
||||||
return f"❌ 错误:图片文件不存在 - {image_path}"
|
return f"❌ 错误:图片文件不存在 - {image_path}"
|
||||||
|
|
||||||
|
mime = detect_image_mime(image_path)
|
||||||
|
if not mime:
|
||||||
|
return f"❌ 错误:文件不是有效图片(仅支持 JPEG/PNG/GIF/WebP/BMP) - {image_path}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
base64_image = encode_image_to_base64(image_path)
|
base64_image = encode_image_to_base64(image_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -55,7 +80,7 @@ def analyze_image(image_path, prompt, stream=False):
|
|||||||
{
|
{
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {
|
"image_url": {
|
||||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
"url": f"data:{mime};base64,{base64_image}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -890,3 +890,25 @@ Linux 基线(`aibox/venv`)维持 **490 passed** 不受影响。
|
|||||||
- 全量测试 **493 passed / 4 failed(平台环境项)/ 1 skipped**,与审计基线完全一致,零回归
|
- 全量测试 **493 passed / 4 failed(平台环境项)/ 1 skipped**,与审计基线完全一致,零回归
|
||||||
|
|
||||||
**波次 2~5 待落地**:路径边界(S2/S9/S11/S12+低危同类)、前端功能(F1/F2 等)、命令面规整(W 系列)、客户端收尾(M1 等)。
|
**波次 2~5 待落地**:路径边界(S2/S9/S11/S12+低危同类)、前端功能(F1/F2 等)、命令面规整(W 系列)、客户端收尾(M1 等)。
|
||||||
|
|
||||||
|
## 十九、Batch M 修复记录(波次 2:路径边界,2026-08-20)
|
||||||
|
|
||||||
|
范围:§17.6 波次 2——用户/AI 可控文件名与人名校验,统一拦截"经 `os.path.join` 逃逸目标目录"类问题(S2/S3/S9/S11/S12 + S6/S10/S13)。
|
||||||
|
|
||||||
|
| # | 修复内容 | 关键变更 |
|
||||||
|
|---|---|---|
|
||||||
|
| S2 | `gitclient.py` `delete` 限工作区 | `os.path.realpath` 双重校验:必须在 `WORKSPACE_DIR` 内且含 `.git` 目录,否则拒绝 |
|
||||||
|
| S3 | `gitclient.py` 分支名白名单 | `_REF_RE=^[A-Za-z0-9][A-Za-z0-9._/@+-]{0,127}$`(首字符禁 `-` 防 git 选项注入),clone/pull/push 三入口统一 `_valid_ref` 拦截 |
|
||||||
|
| S9 | `camera.py` `--filename` 校验 | 新增 `validate_filename`:拒绝 `/`、`\`、绝对路径、`.`/`..`;capture 分支接入,非法即 exit 1 |
|
||||||
|
| S10 | `screenshot.py` `--filename` 校验 | 同款 `validate_filename`,`take_screenshot` 内拦截返回失败(此前 J12 只挡了 PS 注入,路径逃逸未挡) |
|
||||||
|
| S11 | `facerec.py` person_name 白名单 | `_PERSON_NAME_RE=^(?!\.+$)[\w.-]{1,64}$`(`\w` UNICODE 兼容中文人名),add/remove/get-info 三方法统一拦截 |
|
||||||
|
| S12 | `vision.py` 图片魔数校验 | 新增 `detect_image_mime`(JPEG/PNG/GIF/WebP/BMP 魔数白名单),非图片文件拒绝读取外发;data URL MIME 由硬编码 `image/jpeg` 改为按魔数实测 |
|
||||||
|
| S6 | `sftp-transfer.py` 目录成员名校验 | 新增 `safe_member_name`,`download_dir`/`upload_dir` 双向过滤 listdir 条目——防恶意服务端构造 `../../x` 条目在递归下载时逃逸本地目录 |
|
||||||
|
| S13 | `test-auto-appimage.sh` sed 注入 | `create_apprun` 对 `$exec_name` 白名单 `^[A-Za-z0-9._+-]+$`,非法即退出(此前含 `/`/`&` 可破坏/注入 `sed -i "s/EXEC_NAME/.../g"`) |
|
||||||
|
|
||||||
|
**验证**:
|
||||||
|
- 六文件 `py_compile` 通过(Windows 无 bash,`test-auto-appimage.sh` 变更为单条正则守卫,低风险)
|
||||||
|
- 校验器功能实测:camera 放行 `ok.jpg`/拦截 `../evil.jpg`、`a/b`、`a\b`;facerec 放行 `张三`/`Li-Si_2.0`/拦截 `../etc`、`..`、空串;vision PNG 魔数识别正确/文本文件拒绝;sftp 放行正常名/拦截 `..`、`a/b`、`a\b`、空串
|
||||||
|
- 全量测试 **493 passed / 4 failed(既有平台环境项:symlink 权限、`python3` 缺 PATH、Windows 可执行位语义)/ 1 skipped**,与波次 1 基线完全一致,零回归
|
||||||
|
|
||||||
|
**波次 3~5 待落地**:前端功能(F1/F2 等)、命令面规整(W 系列)、客户端收尾(M1 等)。
|
||||||
Reference in new issue
Block a user