文档版本: v0.6.10 | 日期: 2026-07-14 | 基于产品版本: v0.6.10
实现状态说明: 本文第 2 节描述的
SecureIO类是设计参考实现(位于pycc2/core/secure_io.py,使用 PBKDF2 密钥派生)。实际生产代码使用pycc2/infrastructure/save_system.py中的SecureSaveManager类,它使用 HMAC-SHA256 + 环境变量/配置文件提供的密钥(无 PBKDF2 派生)。详见第 2.7 节 "实现差异说明"。
STRIDE 是微软提出的威胁建模方法,涵盖6个安全维度:
| 维度 | 全称 | 说明 | PyCC2适用性 |
|---|---|---|---|
| S | Spoofing (身份伪造) | 冒充合法用户/实体 | |
| T | Tampering (数据篡改) | 非法修改数据/代码 | 🔴 高风险 |
| R | Repudiation (否认) | 否认已执行的操作 | ✅ 低风险(单机游戏) |
| I | Information Disclosure (信息泄露) | 敏感信息暴露 | 🟡 低风险 |
| D | Denial of Service (拒绝服务) | 资源耗尽导致不可用 | 🟡 中等风险 |
| E | Elevation of Privilege (权限提升) | 获取更高权限 | 🔴 高风险(Mod场景) |
威胁描述: 攻击者可能伪造存档文件,冒充正常游戏进度,或通过修改存档获得不公平优势。
攻击向量:
- 直接编辑
.sav存档文件(JSON格式易读) - 复制他人存档冒充自己的进度
- 使用工具批量生成"完美"存档
影响评估:
- 严重程度: 🟡 Medium
- 可能性: High(单机游戏无服务端验证)
- 影响范围: 游戏公平性、成就系统、排行榜(如果未来添加)
缓解措施:
| 措施 | 实现方式 | 优先级 | 状态 |
|---|---|---|---|
| HMAC签名 | SecureIO使用HMAC-SHA256对存档签名 | P0 | ✅ 已实现 |
| 密钥派生 | PBKDF2从设备UUID派生密钥 | P0 | ✅ 已实现 |
| 版本控制 | 存档包含版本号,防止降级攻击 | P1 | ✅ 已实现 |
| 时间戳校验 | 检查存档时间戳合理性 | P2 | ⏳ 计划中 |
HMAC签名方案详情:
importhmacimporthashlibimportjsondefsign_save_data(data: dict, key: bytes) ->tuple:
""" 对存档数据进行HMAC-SHA256签名。 返回: (payload_json, signature_hex) """payload=json.dumps(data, separators=(",", ":"), ensure_ascii=False)
signature=hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
returnpayload, signature威胁描述: 攻击者修改存档中的关键数值(HP、弹药、位置等)以获得游戏优势。
攻击向量:
- 十六进制编辑器直接修改
.sav文件 - 内存注入工具(Cheat Engine等)修改运行时数据
- Python pickle反序列化漏洞利用(如果使用pickle)
- 替换
data/目录下的配置文件
影响评估:
- 严重程度: 🔴 High
- 可能性: Very High(本地单机游戏)
- 影响范围: 游戏平衡性、挑战性、成就感
缓解措施:
| 措施 | 实现方式 | 优先级 | 状态 |
|---|---|---|---|
| HMAC完整性保护 | 所有存档数据必须通过HMAC验证 | P0 | ✅ 已实现 |
| Pydantic严格校验 | 加载时对所有字段进行类型和范围检查 | P0 | ✅ 已实现 |
| 关键值加密 | HP/弹药等敏感字段可选AES加密 | P1 | ⏳ v1.1计划 |
| 内存保护 | 运行时关键数据内存混淆 | P2 | ⏳ v2.0计划 |
| 配置文件哈希 | engine.toml等配置加载时校验哈希 | P1 | ⏳ 计划中 |
Pydantic防御层示例:
frompydanticimportBaseModel, Field, validatorclassUnitSaveData(BaseModel):
current_hp: int=Field(..., ge=0)
@validator("current_hp")defvalidate_hp_not_excessive(cls, v, values):
base_hp=values.get("base_hp", 100)
ifv>base_hp*1.5: # 允许50%缓冲(buff等)raiseValueError(f"HP {v} exceeds reasonable maximum {base_hp*1.5}")
returnv威胁描述: 用户否认执行过某些操作(如删除存档、修改设置等)。
分析结论:
- 严重程度: 🟢 Low
- 适用性: ❌ 不适用于单机游戏
- 理由: PyCC2是纯单机游戏,不存在多用户审计需求。操作日志仅用于调试目的。
- 建议: 无需特殊处理,但可保留基本日志用于问题排查。
威胁描述: 敏感信息(如用户路径、设备信息、内部状态)被意外暴露。
攻击向量:
- 错误日志中包含绝对路径
- 存档文件中包含用户名/机器名
- Debug模式输出泄露内部结构
- 异常堆栈包含敏感路径
影响评估:
- 严重程度: 🟢 Low
- 可能性: Medium
- 影响范围: 用户隐私、潜在社会工程攻击
缓解措施:
| 措施 | 实现方式 | 优先级 | 状态 |
|---|---|---|---|
| 路径脱敏 | 日志中使用相对路径或脱敏处理 | P1 | ✅ 已实现 |
| 不记录绝对路径 | 存档元数据不包含完整路径 | P0 | ✅ 已实现 |
| Debug模式限制 | 生产环境禁用详细日志 | P0 | ✅ 已实现 |
| 错误信息过滤 | 异常消息中过滤路径信息 | P2 | ⏳ 计划中 |
路径脱敏示例:
importosdefsanitize_path(path: str) ->str:
"""将绝对路径转换为相对路径或脱敏"""home=os.path.expanduser("~")
ifpath.startswith(home):
returnpath.replace(home, "~")
# 或者只显示最后两级目录parts=os.path.normpath(path).split(os.sep)
iflen(parts) >3:
returnos.path.join("...", *parts[-3:])
returnpath威胁描述: 恶意输入导致资源耗尽(CPU、内存、磁盘),使程序崩溃或无法响应。
攻击向量:
- 超大地图文件(如65536×65536)导致OOM
- 嵌套递归的JSON配置导致栈溢出
- 极多单位数量(10000+)导致性能崩溃
- 快速连续存档导致磁盘IO饱和
- 深度嵌套的Mod脚本导致无限循环
影响评估:
- 严重程度: 🟡 Medium
- 可能性: Medium(需要恶意意图)
- 影响范围: 程序稳定性、用户体验
缓解措施:
| 措施 | 实现方式 | 限制值 | 优先级 | 状态 |
|---|---|---|---|---|
| 地图尺寸上限 | TileMapConfig field_validator | max 256×256 | P0 | ✅ 已实现 |
| JSON深度限制 | json.load with max_depth | depth ≤ 10 | P0 | ✅ 已实现 |
| 单位数量限制 | 战斗系统硬编码上限 | max 500/unit | P1 | ✅ 已实现 |
| 存档频率限制 | 防抖机制 | 最小间隔1s | P2 | ⏳ 计划中 |
| 内存监控 | 内存超限时警告/拒绝操作 | 阈值512MB | P2 | ⏳ 计划中 |
| 解析器防护 | Pydantic递归深度检测 | max_nesting=20 | P0 | ✅ 已实现 |
Pydantic资源限制示例:
frompydanticimportBaseModel, field_validatorclassTileMapConfig(BaseModel):
width: int=Field(..., ge=16, le=256)
height: int=Field(..., ge=16, le=256)
@field_validator("width", "height")@classmethoddefcheck_map_size(cls, v):
total_tiles=v*v# 假设正方形简化计算max_tiles=256*256# 65536 tilesiftotal_tiles>max_tiles:
raiseValueError(f"Map too large: {total_tiles} tiles exceeds limit {max_tiles}")
returnv威胁描述: 通过Mod系统或脚本执行任意代码,获取系统级权限。
攻击向量:
- 恶意Mod包含
os.system('rm -rf /') - 使用
eval()/exec()执行用户代码 - pickle反序列化执行任意代码
- 导入恶意Python模块
影响评估:
- 严重程度: 🔴 High
- 可能性: 取决于是否开放Mod支持
- 影响范围: 系统安全、用户数据安全
缓解措施:
| 措施 | 实现方式 | 优先级 | 状态 |
|---|---|---|---|
| 禁用动态代码执行 | 不使用eval/exec/importlib动态导入 | P0 | ✅ 已遵循 |
| Mod沙箱(v2.0) | 限制API白名单+禁止危险模块 | P0 | ⏳ 开发中 |
| pickle替代 | 使用JSON而非pickle进行序列化 | P0 | ✅ 已采用 |
| 权限最小化 | 不请求管理员/root权限 | P0 | ✅ 已遵循 |
| 代码审计 | 定期审查依赖库安全性 | P1 | 🔄 持续进行 |
Mod沙箱设计(预留):
# v2.0 计划功能classModSandbox:
ALLOWED_APIS= {
"register_unit_template",
"register_weapon_config",
"register_terrain_type",
"register_objective_type",
}
FORBIDDEN_MODULES= {"os", "sys", "subprocess", "socket", "importlib", "builtins", "__import__"}
defexecute_mod_script(self, script_path: str):
restricted_globals= {
"__builtins__": self._safe_builtins(),
"api": self._create_api_proxy(),
}
withopen(script_path) asf:
code=compile(f.read(), script_path, "exec")
exec(code, restricted_globals) # 受限环境| 威胁维度 | 严重程度 | 可能性 | 风险等级 | 主要缓解措施 | 实现状态 |
|---|---|---|---|---|---|
| S Spoofing | Medium | High | Medium | HMAC签名 + 设备绑定 | ✅ 完成 |
| T Tampering | High | Very High | High | HMAC + Pydantic严格校验 | ✅ 完成 |
| R Repudiation | Low | N/A | Low | 不适用(单机) | N/A |
| I Disclosure | Low | Medium | Low | 路径脱敏 + 日志过滤 | ✅ 基本完成 |
| D DoS | Medium | Medium | Medium | Pydantic限制 + 资源监控 | ✅ 核心完成 |
| E EoP | High | Future | High | 禁用动态代码 + 沙箱(规划) | ✅ 当前安全 |
整体安全评级: 🟢 中等偏上 (对于单机游戏足够安全)
SecureIO 是 PyCC2 的安全存档读写模块,提供:
- 基于HMAC的完整性保护
- 基于PBKDF2的密钥派生
- 原子写入保证(防损坏)
- 版本兼容性管理
模块位置:pycc2/core/secure_io.py
依赖关系:
SecureIO
├── hashlib (HMAC-SHA256)
├── hmac (签名验证)
├── json (序列化)
├── os (原子写入 os.replace)
├── pathlib (路径操作)
└── pydantic (数据校验)
功能: 从设备唯一标识符派生加密密钥,确保每个机器的密钥不同。
算法流程:
macOS ioreg UUID
↓
UTF-8 编码为 bytes
↓
PBKDF2-HMAC-SHA256
├── salt: b'pycc2_secure_salt_v1'
├── iterations: 100,000
└── dklen: 32 bytes (256 bits)
↓
缓存到内存 (进程生命周期内复用)
↓
返回 key: bytes (32字节)
完整实现:
importhashlibimportsubprocessimportfunctoolsfromtypingimportOptionalclassSecureIO:
_derived_key: Optional[bytes] =None_KEY_SALT: Optional[bytes] =None_PBKDF2_ITERATIONS=100_000_KEY_LENGTH=32# 256 bits@classmethoddef_init_salt(cls) ->bytes:
"""生成per-install随机Salt,持久化到本地配置文件。"""ifcls._KEY_SALTisnotNone:
returncls._KEY_SALTimportos, secretssalt_path=os.path.join(os.path.expanduser("~"), ".pycc2", "salt.bin")
os.makedirs(os.path.dirname(salt_path), exist_ok=True)
ifos.path.exists(salt_path):
withopen(salt_path, "rb") asf:
cls._KEY_SALT=f.read()
else:
cls._KEY_SALT=secrets.token_bytes(32)
withopen(salt_path, "wb") asf:
f.write(cls._KEY_SALT)
os.chmod(salt_path, 0o600)
returncls._KEY_SALT@classmethoddef_get_device_uuid(cls) ->str:
"""获取macOS设备的唯一标识符 (ioreg UUID)"""try:
result=subprocess.run(
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
capture_output=True,
text=True,
timeout=5,
)
forlineinresult.stdout.split("\n"):
if"UUID"inlineand'"'inline:
uuid=line.split('"')[-2]
iflen(uuid) >=16:
returnuuidexceptException:
pass# Fallback: 使用machine-id或随机生成importplatformfallback=f"{platform.node()}-{platform.machine()}"returnhashlib.sha256(fallback.encode()).hexdigest()
@classmethoddef_derive_key(cls) ->bytes:
""" 使用PBKDF2从设备UUID派生密钥。 结果缓存在类变量中避免重复计算。 """ifcls._derived_keyisnotNone:
returncls._derived_keydevice_uuid=cls._get_device_uuid().encode("utf-8")
salt=cls._init_salt()
key=hashlib.pbkdf2_hmac(
"sha256", device_uuid, salt, cls._PBKDF2_ITERATIONS, dklen=cls._KEY_LENGTH
)
cls._derived_key=keyreturnkey性能特征:
- 首次调用: ~200-500ms (100k次迭代)
- 后续调用: <0.1ms (缓存命中)
- 内存占用: 32 bytes/key
功能: 将游戏数据序列化、签名后安全写入磁盘。
流程图:
SaveGameData (Pydantic Model)
↓
[1] Pydantic校验 → dict()
↓
[2] JSON序列化 (compact format)
↓ separator=(',', ':') ensure_ascii=False
↓
[3] HMAC-SHA256签名
├── key = _derive_key()
└── sig = hex(hmac(key, payload))
↓
[4] 组合格式: "{payload}\n#SIG:{signature}"
↓
[5] 原子写入 (Atomic Write)
├── 写入临时文件 .tmp
├── fsync() 强制刷盘
└── os.rename(.tmp → .sav) [原子操作]
↓
✅ 写入成功
完整实现:
importjsonimporthmacimporthashlibimportosimporttempfilefrompathlibimportPathfromdatetimeimportdatetimefromtypingimportAny, DictclassSaveCorruptedError(Exception):
passclassSaveVersionError(Exception):
passclassSecureIO:
SIG_PREFIX="#SIG:"SAVE_VERSION="1.0.0"@classmethoddefwrite_save(cls, save_path: Path, data: Dict[str, Any], *, dev_mode: bool=False) ->bool:
""" 安全写入存档文件。 Args: save_path: 目标文件路径 (.sav) data: 要保存的数据字典 (应符合SaveGameData schema) dev_mode: 开发模式下跳过签名 (用于测试) Returns: True 如果成功 Raises: ValueError: 数据校验失败 IOError: 写入失败 """# Step 1: 序列化为紧凑JSONtry:
payload=json.dumps(
data,
separators=(",", ":"),
ensure_ascii=False,
sort_keys=True,
default=str, # 处理datetime等非标准类型
)
except (TypeError, ValueError) ase:
raiseValueError(f"Serialization failed: {e}")
# Step 2: 生成HMAC签名 (除非dev_mode)ifnotdev_mode:
key=cls._derive_key()
signature=hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
file_content=f"{payload}\n{cls.SIG_PREFIX}{signature}"else:
file_content=f"{payload}\n{cls.SIG_PREFIX}DEV_MODE_NO_SIG"# Step 3: 原子写入save_path=Path(save_path)
save_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path=tempfile.mkstemp(dir=save_path.parent, suffix=".tmp")
try:
withos.fdopen(fd, "w", encoding="utf-8") asf:
f.write(file_content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, str(save_path))
returnTrueexceptException:
ifos.path.exists(tmp_path):
os.unlink(tmp_path)
raise存档文件格式示例:
{"version":"1.0.0","timestamp":"2024-01-15T10:30:00","campaign_state":{"mission_index":0,...},"squads":{...}}
#SIG:a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890格式说明:
- 第一行: JSON payload (紧凑格式, 无空格)
- 第二行: HMAC签名 (以
#SIG:前缀标识) - 签名长度: 64个hex字符 (SHA256 = 256 bits)
功能: 从磁盘读取存档,验证签名和格式后返回数据。
流程图:
.sav 文件
↓
[1] 读取全部内容
↓
[2] 分离 payload 和 signature
├── 查找最后一个 '\n'
├── 前 payload = content[:last_newline]
└── 后 sig_line = content[last_newline+1:]
↓
[3] 签名验证 (非dev_mode)
├── 提取 sig_hex (去掉 "#SIG:" 前缀)
├── 重新计算 HMAC(payload, key)
└── 比较恒定时间 (hmac.compare_digest)
↓
├── ✅ 验证通过 → 继续
└── ❌ 验证失败 → raise SaveCorruptedError
↓
[4] JSON反序列化
↓
[5] Pydantic校验 (SaveGameData model)
├── 类型检查
├── 范围约束
└── 业务规则
↓
├── ✅ 校验通过 → 返回 SaveGameData 对象
└── ❌ 校验失败 → raise ValidationError
完整实现:
importjsonimporthmacimporthashlibfrompathlibimportPathfromtypingimportDict, AnyclassSecureIO:
@classmethoddefread_save(
cls,
save_path: Path,
*,
dev_mode: bool=False
) ->Dict[str, Any]:
""" 安全读取并验证存档文件。 Args: save_path: 存档文件路径 dev_mode: 开发模式下跳过签名验证 Returns: 解析后的数据字典 Raises: FileNotFoundError: 文件不存在 SaveCorruptedError: 文件损坏或签名无效 SaveVersionError: 版本不兼容 ValueError: JSON解析失败或数据校验失败 """save_path=Path(save_path)
ifnotsave_path.exists():
raiseFileNotFoundError(f"Save file not found: {save_path}")
# Step 1: 读取原始内容try:
raw_content=save_path.read_text(encoding='utf-8')
exceptUnicodeDecodeErrorase:
raiseSaveCorruptedError(f"File encoding error: {e}")
exceptIOErrorase:
raiseSaveCorruptedError(f"Read error: {e}")
ifnotraw_content.strip():
raiseSaveCorruptedError("Save file is empty")
# Step 2: 分离payload和签名last_newline=raw_content.rfind('\n')
iflast_newline==-1:
raiseSaveCorruptedError("Invalid file format: no signature line")
payload=raw_content[:last_newline]
sig_line=raw_content[last_newline+1:].strip()
ifnotsig_line.startswith(cls.SIG_PREFIX):
raiseSaveCorruptedError("Invalid signature format")
stored_sig=sig_line[len(cls.SIG_PREFIX):]
# Step 3: 验证签名 (非dev模式)ifnotdev_mode:
ifstored_sig=="DEV_MODE_NO_SIG":
raiseSaveCorruptedError("Dev-mode save cannot be loaded in normal mode")
key=cls._derive_key()
computed_sig=hmac.new(
key,
payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
ifnothmac.compare_digest(computed_sig, stored_sig):
raiseSaveCorruptedError(
"HMAC verification failed - file may be tampered"
)
else:
print("[WARNING] Dev mode: skipping signature verification")
# Step 4: JSON反序列化try:
data=json.loads(payload)
exceptjson.JSONDecodeErrorase:
raiseSaveCorruptedError(f"JSON parse error at position {e.pos}: {e.msg}")
ifnotisinstance(data, dict):
raiseSaveCorruptedError("Save data must be a JSON object")
# Step 5: 版本检查saved_version=data.get('version', 'unknown')
ifsaved_version!=cls.SAVE_VERSIONandsaved_version!='unknown':
ifnotcls._is_version_compatible(saved_version):
raiseSaveVersionError(
f"Incompatible save version: {saved_version} "f"(expected {cls.SAVE_VERSION})"
)
returndata@classmethoddef_is_version_compatible(cls, version: str) ->bool:
""" 检查版本兼容性。 未来可实现版本迁移逻辑。 """major=int(version.split('.')[0])
current_major=int(cls.SAVE_VERSION.split('.')[0'])
returnmajor<=current_major异常层次结构:
Exception
├── SaveCorruptedError # 存档损坏/篡改
│ ├── 签名验证失败
│ ├── JSON解析错误
│ └── 格式错误
├── SaveVersionError # 版本不兼容
│ └── 需要迁移或拒绝加载
├── FileNotFoundError # 文件不存在 (标准异常)
└── ValueError # 数据内容非法 (Pydantic抛出)
classSecureIO:
@classmethoddefverify_save_integrity(cls, save_path: Path) ->tuple[bool, str]:
""" 快速验证存档完整性(不加载全部数据)。 Returns: (is_valid: bool, message: str) """try:
cls.read_save(save_path)
returnTrue, "Valid"exceptSaveCorruptedErrorase:
returnFalse, f"Corrupted: {e}"exceptSaveVersionErrorase:
returnFalse, f"Version error: {e}"exceptExceptionase:
returnFalse, f"Error: {e}"@classmethoddefget_save_metadata(cls, save_path: Path) ->dict:
""" 仅读取存档元数据(轻量级操作)。 Returns: 包含version, timestamp, mission_index等的字典 """data=cls.read_save(save_path)
return {
"version": data.get("version"),
"timestamp": data.get("timestamp"),
"mission_index": data.get("campaign_state", {}).get("mission_index"),
"squad_count": len(data.get("squads", {})),
"file_size": save_path.stat().st_size,
}
@classmethoddefcreate_backup(cls, save_path: Path) ->Path:
""" 创建存档备份 (带时间戳)。 """timestamp=datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path=save_path.with_suffix(f".sav.bak_{timestamp}")
importshutilshutil.copy2(save_path, backup_path)
returnbackup_path@classmethoddeflist_saves(cls, saves_dir: Path) ->list[dict]:
""" 列出目录下所有有效存档及其元数据。 """saves= []
forsave_fileinsorted(saves_dir.glob("*.sav")):
try:
metadata=cls.get_save_metadata(save_file)
metadata["path"] =str(save_file)
metadata["filename"] =save_file.namesaves.append(metadata)
exceptException:
saves.append(
{
"path": str(save_file),
"filename": save_file.name,
"valid": False,
"error": "Cannot read",
}
)
returnsaves| 操作 | 平均耗时 | P95耗时 | 内存占用 | 备注 |
|---|---|---|---|---|
| write_save (首次) | ~350ms | ~600ms | ~2MB | 含PBKDF2密钥派生 |
| write_save (后续) | ~15ms | ~30ms | ~2MB | 使用缓存密钥 |
| read_save (验证通过) | ~20ms | ~40ms | ~3MB | 含完整校验 |
| read_save (签名失败) | ~5ms | ~10ms | ~1MB | 快速失败 |
| verify_integrity | ~25ms | ~50ms | ~3MB | 完整验证 |
| get_metadata | ~22ms | ~45ms | ~3MB | 同read_save |
测试环境: MacBook Pro M1, 16GB RAM, SSD
重要: 本节描述 SecureIO(设计参考)与 SecureSaveManager(实际生产实现)的差异。
| 特性 | SecureIO(设计参考) | SecureSaveManager(实际实现) |
|---|---|---|
| 文件位置 | pycc2/core/secure_io.py | pycc2/infrastructure/save_system.py |
| 密钥派生 | PBKDF2-HMAC-SHA256 (100k 迭代) | 无派生,直接从 env/config 读取 |
| 密钥来源 | 设备 UUID + per-install salt | PYCC2_SAVE_HMAC_KEY 环境变量或配置文件 |
| 签名算法 | HMAC-SHA256 | HMAC-SHA256 ✅ 一致 |
| 恒定时间比较 | hmac.compare_digest | hmac.compare_digest ✅ 一致 |
| 原子写入 | os.replace | os.replace ✅ 一致 |
生产实现的安全特性:
- ✅ HMAC-SHA256 签名完整性保护
- ✅
hmac.compare_digest恒定时间比较 - ✅ 原子写入(
os.replace) - ✅ 版本控制
⚠️ 密钥来自环境变量/配置文件(非 PBKDF2 派生)⚠️ 无密钥时回退到临时随机密钥(仅开发用,重启后失效)
为何未使用 PBKDF2: 生产实现选择 env/config 密钥而非 PBKDF2 派生,原因是:
- 单机游戏无需设备绑定(无多用户/云端场景)
- PBKDF2 100k 迭代首次调用 ~200-500ms,影响游戏启动体验
- env/config 密钥更灵活,便于 CI/CD 测试和部署
SecureIO设计保留为未来 v1.0 正式版的可选升级路径
PyCC2 有7个主要的数据输入入口,每个都需要严格的校验:
| 入口ID | 名称 | 数据来源 | 校验目标模型 | 信任级别 | 校验强度 |
|---|---|---|---|---|---|
| IN-01 | 地图JSON文件 | data/maps/*.json | TileMapConfig | 低(外部文件) | 🔴 严格 |
| IN-02 | 单位模板JSON | data/units/*.json | UnitTemplate | 低 | 🔴 严格 |
| IN-03 | 武器配置JSON | data/weapons/*.json | WeaponConfig | 低 | 🔴 严格 |
| IN-04 | 引擎配置TOML | engine.toml | EngineConfig | 中(用户可编辑) | 🟠 较严 |
| IN-05 | 存档文件 | saves/*.sav | SaveGameData + HMAC | 低(可被篡改) | 🔴 最严格 |
| IN-06 | 命令行参数 | sys.argv | argparse Namespace | 中 | 🟡 标准 |
| IN-07 | Pygame事件 | pygame.event | IInputHandler内部 | 高(框架处理) | 🟢 基本 |
校验链路:
map.json 文件
↓
json.load() → dict
↓
TileMapConfig(**dict) [Pydantic自动校验]
├── width ∈ [16, 256]
├── height ∈ [16, 256]
├── terrain_grid: List[List[int]]
│ ├── 非空检查
│ ├── 矩形形状检查 (所有行等长)
│ └── 值域检查 (0-11, 对应TerrainType枚举)
├── spawn_points: List[SpawnPoint]
│ ├── 位置在地图范围内
│ └── faction 为 allies 或 axis
└── objectives: List[Objective]
├── 位置在地图范围内
└── type 为合法ObjectiveType
↓
✅ 通过 → TileMapConfig实例可用
❌ 失败 → ValidationError (详细字段级错误)
关键校验代码:
classTileMapConfig(BaseModel):
width: int=Field(..., ge=16, le=256)
height: int=Field(..., ge=16, le=256)
terrain_grid: List[List[int]]
spawn_points: List[SpawnPoint]
objectives: List[Objective]
@model_validator(mode="after")defvalidate_consistency(self):
grid_h=len(self.terrain_grid)
grid_w=len(self.terrain_grid[0]) ifgrid_h>0else0ifgrid_h!=self.heightorgrid_w!=self.width:
raiseValueError(
f"Grid dimensions ({grid_w}x{grid_h}) don't match "f"declared size ({self.width}x{self.height})"
)
forspinself.spawn_points:
ifnot (0<=sp.position_x<self.widthand0<=sp.position_y<self.height):
raiseValueError(f"Spawn point {sp.id} out of bounds")
returnself共享校验模式:
classUnitTemplate(BaseModel):
id: str=Field(..., pattern=r"^[a-z][a-z0-9_]*$") # ID格式约束name: str=Field(..., min_length=1, max_length=64)
faction: Literal["allies", "axis"]
unit_type: Literal[
"infantry_rifle",
"infantry_mg",
"infantry_at",
"infantry_commander",
"vehicle_light",
"vehicle_medium",
"support_mortar",
]
staff_count_default: int=Field(default=10, ge=1, le=100)
move_speed: float=Field(default=3.0, ge=0.5, le=20.0)
base_hp: int=Field(..., gt=0, le=1000)
weapons: List[str] =Field(..., min_length=1, max_length=10)
classWeaponConfig(BaseModel):
id: str=Field(..., pattern=r"^[a-z][a-z0-9_]*$")
caliber: str=Field(..., min_length=1, max_length=32)
range_max: int=Field(..., gt=0, le=10000)
range_effective: int=Field(..., gt=0, le="range_max")
damage_base: int=Field(..., gt=0, le=1000)
rof: int=Field(..., gt=0, le=5000)
magazine_size: int=Field(..., gt=0, le=1000)
@model_validator(mode="after")defvalidate_range_logic(self):
ifself.range_effective>self.range_max:
raiseValueError(
f"Effective range ({self.range_effective}) cannot exceed "f"max range ({self.range_max})"
)
returnself特点: 用户可编辑,需要容错性好
try:
config=EngineConfig.model_validate(toml.load("engine.toml"))
exceptValidationErrorase:
logger.warning(f"Config validation error: {e}")
config=EngineConfig() # 回退到默认值logger.info("Using default configuration")容错策略:
- 缺失字段 → 使用默认值
- 类型错误 → 尝试转换,失败则用默认值
- 超出范围 → clamp到最近合法值
- 完全无法解析 → 使用完全默认配置 + 日志警告
最严格的校验(见第2节SecureIO):
存档文件
↓
[1] 文件存在性和大小检查 (>0 bytes, <10MB)
↓
[2] 格式检查 (必须含 \n 和 #SIG: 前缀)
↓
[3] HMAC-SHA256签名验证 (防篡改)
↓
[4] JSON语法正确性
↓
[5] Pydantic SaveGameData模型校验
├── 版本号兼容性
├── campaign_state 完整性
├── squads 字典结构
│ └── 每个UnitSaveData
│ ├── hp ≥ 0 且 ≤ base_hp*1.5
│ ├── morale ∈ [0, 100]
│ └── ammo ≥ 0
└── game_tick 单调递增 (可选)
↓
✅ 全部通过 → 返回强类型对象
importargparsedefparse_args():
parser=argparse.ArgumentParser(description="PyCC2 - WWII Tactical Combat Game")
parser.add_argument(
"--map", type=str, help="Path to map JSON file", default="data/maps/tutorial.json"
)
parser.add_argument("--save", type=str, help="Path to save file to load", default=None)
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
parser.add_argument(
"--dev-mode", action="store_true", help="Developer mode (skip security checks)"
)
parser.add_argument(
"--resolution", type=str, help="Window resolution (WIDTHxHEIGHT)", default="1280x720"
)
args=parser.parse_args()
# 额外校验分辨率格式ifargs.resolution:
try:
w, h=map(int, args.resolution.split("x"))
assert640<=w<=4096and480<=h<=2160except (ValueError, AssertionError):
parser.error(f"Invalid resolution: {args.resolution}")
returnargs内部校验(在事件处理循环中进行):
classInputHandler:
MAX_CLICK_RATE=30# Hz (防连点)MIN_DRAG_DISTANCE=5# px (防误触)defhandle_event(self, event):
ifevent.type==pygame.MOUSEBUTTONDOWN:
x, y=event.pos# 坐标范围检查ifnot (0<=x<SCREEN_WIDTHand0<=y<SCREEN_HEIGHT):
return# 忽略超出屏幕的坐标# 防抖动ifevent.button==1:
now=pygame.time.get_ticks()
ifnow-self.last_click_time<1000//self.MAX_CLICK_RATE:
return# 过滤过快的点击self.last_click_time=nowelifevent.type==pygame.KEYDOWN:
key=event.key# 只接受已知键位VALID_KEYS= {
K_UP,
K_DOWN,
K_LEFT,
K_RIGHT,
K_w,
K_a,
K_s,
K_d,
K_SPACE,
K_ESCAPE,
K_RETURN,
K_F11,
K_HOME,
K_END,
K_f,
K_m,
}
ifkeynotinVALID_KEYSandnotself.debug_mode:
return# 忽略未知按键为未来Mod支持预留安全沙箱架构,允许玩家自定义内容的同时防止恶意代码执行。
核心原则:
- 默认拒绝 (Deny by Default)
- 最小权限原则 (Least Privilege)
- 资源限制 (Resource Limits)
- API白名单 (Whitelist Only)
MOD_ALLOWED_APIS= {
# === 注册类API (安全, 只写) ==="register_unit_template", # 注册新单位模板"register_weapon_config", # 注册新武器配置"register_terrain_type", # 注册自定义地形属性"register_objective_type", # 注册目标类型"register_mission", # 注册自定义任务# === 查询类API (只读) ==="get_terrain_at", # 查询地形类型"get_distance", # 计算两点距离"get_units_in_radius", # 获取范围内单位列表"get_game_tick", # 获取当前tick"get_difficulty", # 获取难度设置# === 事件订阅 (受控) ==="on_unit_killed", # 单位死亡回调"on_objective_captured", # 目标占领回调"on_mission_start", # 任务开始回调"on_mission_end", # 任务结束回调# === UI扩展 (受限) ==="add_context_menu_item", # 添加右键菜单项"show_notification", # 显示通知横幅"log_to_console", # 输出到控制台 (限速)
}API使用规则:
| 规则 | 说明 | 示例 |
|---|---|---|
| 只能调用白名单API | 其他任何调用都会被拦截 | os.system() → SecurityError |
| 参数类型强制检查 | 所有参数经过Pydantic校验 | register_unit_template(id=123) → TypeError |
| 返回值深拷贝 | 防止修改内部状态 | get_units_in_radius() 返回副本 |
| 调用频率限制 | 防止DoS | log_to_console() 最大10次/秒 |
| 作用域隔离 | Mod之间不能互相访问变量 | Mod A 无法读取 Mod B 的变量 |
MOD_FORBIDDEN_MODULES= {
# === 系统交互 ==="os", # 文件系统/进程操作"sys", # 系统参数/路径"subprocess", # 子进程执行"shutil", # 高级文件操作# === 网络 ==="socket", # 网络通信"urllib", # HTTP请求"requests", # HTTP客户端"http", # HTTP服务器/客户端"ftplib", # FTP"telnetlib", # Telnet# === 代码执行 ==="importlib", # 动态导入"importlib.util",
"__import__", # 内置导入"compile", # 代码编译"exec", # 代码执行"eval", # 表达式求值"types", # 动态类型创建# === 序列化安全 ==="pickle", # 反序列化RCE风险"marshal", # 二进制码执行"shelve", # 持久化对象"yaml", # YAML可执行任意Python (!python/tag)# === 其他危险 ==="ctypes", # C库调用"multiprocessing", # 多进程逃逸"threading", # 线程 (可能导致死锁/竞态)"signal", # 信号处理"pty", # 终端模拟
}拦截机制:
importbuiltinsimportsysclassSafeBuiltins(dict):
"""安全的内置命名空间"""_ALLOWED_BUILTINS= {
"True",
"False",
"None",
"int",
"float",
"str",
"bool",
"list",
"dict",
"tuple",
"set",
"len",
"range",
"enumerate",
"zip",
"map",
"filter",
"min",
"max",
"sum",
"abs",
"round",
"print", # 允许但重定向到安全logger"isinstance",
"issubclass",
"type",
"hasattr",
"getattr",
"setattr", # 受限版"Exception",
"ValueError",
"TypeError",
"RuntimeError",
"__name__",
"__doc__",
}
def__missing__(self, key):
ifkeynotinself._ALLOWED_BUILTINS:
raiseSecurityError(
f"Forbidden builtin: '{key}'. This operation is not allowed in mod sandbox."
)
returnbuiltins.__dict__[key]| 资源类型 | 限制值 | 监控方式 | 超限处理 |
|---|---|---|---|
| CPU时间 | < 5秒/次调用 | time.time()计时 | TimeoutError,终止执行 |
| 内存占用 | < 100MB/tracemalloc | tracemalloc跟踪 | MemoryError,回滚 |
| 文件I/O | 只读mods/目录 | 自定义open钩子 | PermissionError |
| 网络访问 | 完全禁止 | socket拦截 | NetworkForbiddenError |
| 子进程 | 完全禁止 | subprocess拦截 | SubprocessForbiddenError |
| 线程创建 | 禁止 | threading拦截 | ThreadingError |
| 调用深度 | 最大100帧 | sys.setrecursionlimit | RecursionError |
| 输出大小 | console最大1KB/次 | buffer截断 | 截断+警告 |
| 循环次数 | 最大10000次迭代 | 指令计数器 | LoopLimitExceeded |
资源监控实现示例:
importresourceimporttimeimporttracemallocclassResourceMonitor:
MAX_CPU_SECONDS=5.0MAX_MEMORY_MB=100MAX_INSTRUCTIONS=10_000def__init__(self):
self.start_time=Noneself.instruction_count=0tracemalloc.start()
def__enter__(self):
self.start_time=time.monotonic()
self.instruction_count=0returnselfdef__exit__(self, exc_type, exc_val, exc_tb):
tracemalloc.stop()
returnFalse# 不吞掉异常defcheck_cpu(self):
elapsed=time.monotonic() -self.start_timeifelapsed>self.MAX_CPU_SECONDS:
raiseTimeoutError(
f"Mod execution exceeded CPU time limit ({elapsed:.1f}s > {self.MAX_CPU_SECONDS}s)"
)
defcheck_memory(self):
current, peak=tracemalloc.get_traced_memory()
current_mb=current/ (1024*1024)
ifcurrent_mb>self.MAX_MEMORY_MB:
raiseMemoryError(
f"Mod memory usage exceeded limit ({current_mb:.1f}MB > {self.MAX_MEMORY_MB}MB)"
)
defincrement_instruction(self):
self.instruction_count+=1ifself.instruction_count%1000==0: # 每1000条指令检查一次self.check_cpu()
self.check_memory()
ifself.instruction_count>self.MAX_INSTRUCTIONS:
raiseLoopLimitExceeded(
f"Instruction count exceeded limit "f"({self.instruction_count} > {self.MAX_INSTRUCTIONS})"
)mods/
├── my_custom_mod/
│ ├── mod.json # Mod元数据和声明
│ ├── main.py # Mod主逻辑 (沙箱中执行)
│ ├── units/ # 自定义单位定义
│ │ ├── super_soldier.json
│ │ └── heavy_tank.json
│ ├── weapons/ # 自定义武器
│ │ └── plasma_rifle.json
│ └── assets/ # 图标/音效 (只读)
│ └── icon.png
└── another_mod/
└── ...
mod.json 结构:
{
"id": "my_custom_mod",
"version": "1.0.0",
"name": "My Custom Mod",
"author": "PlayerName",
"description": "Adds new units and weapons",
"api_version": "2.0",
"permissions": [
"register_unit_template",
"register_weapon_config",
"add_context_menu_item"
],
"entry_point": "main.py",
"required_game_version": ">=1.0.0"
}
加载时安全检查清单:
- mod.json 格式合法性
- entry_point 文件存在且为.py
- permissions 只包含 ALLOWED_APIS 子集
- 不包含 FORBIDDEN_MODULES 的import语句 (静态扫描)
- 数字签名验证 (可选, 未来)
- 社区评分/审核状态检查 (可选, 在线功能)
| # | 检查项 | 类别 | 优先级 | 状态 | 验证方法 |
|---|---|---|---|---|---|
| SEC-01 | SecureIO 实现完整 | 数据完整性 | P0 | ✅ 已完成 | 单元测试覆盖 |
| SEC-02 | Pydantic Models 严格校验 | 输入验证 | P0 | ✅ 已完成 | model_validate测试 |
| SEC-03 | dev_mode 安全开关 | 开发/生产分离 | P0 | ✅ 已完成 | 环境变量检查 |
| SEC-04 | .gitignore 保护敏感文件 | 源码安全 | P0 | ✅ 已完成 | git status验证 |
| SEC-05 | 存档版本兼容机制 | 向前兼容 | P1 | ⏳ 进行中 | 版本迁移测试 |
| SEC-06 | 依赖安全审计 | 供应链安全 | P0 | 🔄 持续 | pip-audit周期扫描 |
| SEC-07 | 错误信息脱敏 | 信息泄露防护 | P1 | ⏳ 进行中 | 日志审查 |
| SEC-08 | 禁用危险Python特性 | 代码执行防护 | P0 | ✅ 已完成 | grep扫描 |
要求:
- HMAC 密钥来自环境变量/配置文件(生产实现
SecureSaveManager) - [~]
_derive_key()使用PBKDF2 100k迭代(仅设计参考SecureIO,生产未采用,见 2.7 节) write_save()生成HMAC-SHA256签名read_save()验证签名 (compare_digest恒定时间)- 原子写入 (os.replace)
- 异常分类 (SaveCorruptedError / SaveVersionError)
- 单元测试覆盖率 >90%
测试命令:
pytest tests/test_secure_io.py -v --cov=src/pycc2/core/secure_io --cov-report=term-missing要求:
- 所有外部输入都有对应Pydantic Model
- 使用Field约束 (ge/le/pattern/min_length/max_length)
- 使用@validator/@model_validator进行复杂校验
- 不使用Any类型接收外部数据
- 不使用Optional过度 (该必填就必填)
代码质量门禁:
mypy src/pycc2/models/ --strict
ruff check src/pycc2/models/实现:
importosdefis_dev_mode() ->bool:
"""检查是否处于开发者模式"""returnos.environ.get("PYCC2_DEV_MODE", "").lower() in ("1", "true", "yes") orgetattr(
get_engine_config().debug, "dev_mode", False
)
# 在关键安全位置使用ifnotis_dev_mode():
secure_io.verify_signature(save_data)
else:
logger.warning("⚠️ DEV MODE: Security checks disabled")环境变量传播:
# 不要提交到版本控制!# .env.local (gitignored)PYCC2_DEV_MODE=false必需忽略的文件/目录:
# === PyCC2 项目 .gitignore ===# === 存档数据 (含潜在个人信息) ===saves/*.savsaves/*.bak_*!saves/.gitkeep# === 密钥和凭证 ===key.**.pem*.key.secrets/
# === 日志 (可能含路径信息) ===logs/*.log!logs/.gitkeep# === Python ===__pycache__/
*.py[cod]
*$py.class*.so.Pythondist/
build/
*.egg-info/
.eggs/
# === IDE ===.vscode/
.idea/
*.swp*.swo*~# === OS ===.DS_StoreThumbs.db# === 环境 ===.env.env.local.env.*.local.venv/
venv/
# === 调试 ===debug_*.jsonprofile_*.prof验证命令:
# 检查是否有敏感文件被意外提交
git ls-files | grep -E '\.(sav|key|pem|log)$'&&echo"WARNING: Sensitive files tracked!"echo"Check .gitignore effectiveness"当前状态: v1.0 基础版本检查
未来路线图:
| 版本 | 变更内容 | 迁移策略 |
|---|---|---|
| v1.0 | 初始格式 | - |
| v1.1 | 新增metadata字段 | 向后兼容 (旧字段保留) |
| v1.2 | SquadSaveData重构 | 自动转换器 |
| v2.0 | 新加密格式 | 双格式支持期 (6个月) |
迁移代码框架:
MIGRATORS= {
("1.0", "1.1"): migrate_v1_to_v1_1,
("1.1", "1.2"): migrate_v1_1_to_v1_2,
}
defmigrate_save(data: dict, from_ver: str, to_ver: str) ->dict:
key= (from_ver, to_ver)
ifkeynotinMIGRATORS:
raiseSaveVersionError(f"No migrator for {from_ver} → {to_ver}")
returnMIGRATORS[key](data)自动化流水线:
# .github/workflows/security_audit.ymlname: Security Auditon: [push, pull_request]jobs:
pip-audit:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3with:
python-version: '3.11'
- name: Install dependenciesrun: | pip install -e ".[dev]" pip install pip-audit - name: Run pip-auditrun: pip-audit --desc -r requirements.txt || true
- name: Check for vulnerabilitiesrun: | pip-audit -r requirements.txt 2>&1 | tee audit_result.txt if grep -q "Vulnerability" audit_result.txt; then echo "::warning::Security vulnerabilities found!" exit 1 fi手动审计命令:
pip-audit -r requirements.txt --dry-run # 仅查看, 不阻断已知安全依赖策略:
- Pydantic v2+: 积极维护, 安全记录良好
- Pygame: 成熟稳定, 但需关注SDL底层
- 避免: pickle, yaml (unsafe_load), eval
实施指南:
importtracebackimportosdefsafe_format_exception(e: Exception) ->str:
"""格式化异常信息, 脱敏敏感路径"""msg=str(e)
# 脱敏绝对路径home=os.path.expanduser("~")
msg=msg.replace(home, "~")
msg=msg.replace(os.getcwd(), ".")
# 限制长度 (防止信息泄漏过多)iflen(msg) >500:
msg=msg[:500] +"... (truncated)"returnmsg# 在顶层异常处理器中使用try:
risky_operation()
exceptExceptionase:
logger.error(f"Operation failed: {safe_format_exception(e)}")
ifnotis_dev_mode():
user_message="An error occurred. Please check logs."else:
user_message=str(e) # 开发模式显示详细信息grep扫描命令:
# 危险模式扫描
grep -rn "eval(" src/ --include="*.py"| grep -v "test_"&&echo"FOUND eval!"
grep -rn "exec(" src/ --include="*.py"| grep -v "test_"&&echo"FOUND exec!"
grep -rn "import pickle" src/ --include="*.py"&&echo"FOUND pickle!"
grep -rn "yaml.unsafe" src/ --include="*.py"&&echo"FOUND unsafe yaml!"# 应该全部无输出 (除了test文件中的测试用例)CI集成 (作为pre-commit hook):
# .pre-commit-config.yamlrepos:
- repo: localhooks:
- id: no-dangerous-functionsname: Check for dangerous functionsentry: bash -c 'grep -rnE "(eval\(|exec\(|pickle\.load|yaml\.unsafe)" src/ --include="*.py" | grep -v test_'language: systemtypes: [python]pass_filenames: falsealways_run: true总体评级: 🟢 B+ (良好)
优势:
- ✅ SecureIO提供了完整的存档完整性保护
- ✅ Pydantic实现了全面的输入验证
- ✅ dev_mode机制清晰分离开发和生产环境
- ✅ 无网络/多用户功能,攻击面较小
待改进:
⚠️ HMAC密钥派生依赖本地存储的salt文件 (审计发现):_init_salt()生成的~/.pycc2/salt.bin为per-install随机值但持久化在磁盘上,若攻击者获取该文件+设备UUID则可伪造签名。建议: v1.5引入系统keychain集成 (macOS Keychain / Linux Secret Service)⚠️ Mod沙箱尚未实现 (v2.0计划)⚠️ 存档版本迁移机制需完善⚠️ 依赖审计需自动化⚠️ 错误信息脱敏未全面覆盖
下一步行动:
- 完成SEC-05 存档版本迁移 (Q2 2024)
- 建立CI/CD自动安全扫描 (Q1 2024)
- 设计Mod沙箱详细规格 (Q3 2024)
- 第三方安全审计 (可选, Q4 2024)
| 文件路径 | 用途 | 安全级别 |
|---|---|---|
src/pycc2/core/secure_io.py | SecureIO核心实现 | 🔴 核心 |
src/pycc2/models/ | Pydantic数据模型 | 🔴 核心 |
src/pycc2/config.py | 配置加载 | 🟠 重要 |
src/pycc2/input/handler.py | 输入处理 | 🟠 重要 |
.gitignore | Git忽略规则 | 🔴 重要 |
.env.example | 环境变量模板 | 🟢 参考 |
docs/SECURITY.md | 本文档 | 📄 文档 |
发现安全问题
↓
评估严重程度 (P0/P1/P2)
↓
├── P0 (紧急): 24小时内修复 + hotfix发布
│ ├── 停止使用受影响功能
│ ├── 发布安全公告
│ └── 强制更新提示
│
├── P1 (高): 1周内修复 + 下个版本包含
│ ├── 记录到issue tracker
│ ├── 临时缓解措施
│ └── 版本更新说明
│
└── P2 (低): 正常迭代周期修复
├── backlog记录
└── 版本更新说明
↓
修复验证
├── 回归测试
├── 安全专项测试
└── Code Review
↓
关闭issue + 更新文档
- OWASP Top 10 - Web应用安全风险
- CWE (Common Weakness Enumeration) - 软件弱点分类
- STRIDE Methodology - 微软威胁建模
- Pydantic Security Best Practices - 数据验证最佳实践
- Python Security Best Practices - Python安全指南
| 版本 | 日期 | 作者 | 变更内容 |
|---|---|---|---|
| v1.0 | 2024-01 | PyCC2 Security Team | 初始安全评审报告 |
| v1.1 | 2024-02 | - | 补充Mod沙箱设计细节 |
| v1.2 | 2025-05-18 | Security Audit | P3-Fix审计同步: 新增HMAC密钥派生审计发现(salt文件持久化风险), 安全评级维持B+ |