security_utils.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. #!/usr/bin/env python3
  2. """
  3. 安全工具函数库
  4. 用于webnovel-writer系统的通用安全函数
  5. 创建时间: 2026-01-02
  6. 创建原因: 安全审计发现路径遍历和命令注入漏洞
  7. 修复方案: 集中管理所有安全相关的输入清理函数
  8. """
  9. import os
  10. import re
  11. import sys
  12. from pathlib import Path
  13. from typing import Optional
  14. def sanitize_filename(name: str, max_length: int = 100) -> str:
  15. """
  16. 清理文件名,防止路径遍历攻击 (CWE-22)
  17. 安全关键函数 - 修复extract_entities.py路径遍历漏洞
  18. Args:
  19. name: 原始文件名(可能包含路径遍历字符)
  20. max_length: 文件名最大长度(默认100字符)
  21. Returns:
  22. 安全的文件名(仅包含基本文件名,移除所有路径信息)
  23. 示例:
  24. >>> sanitize_filename("../../../etc/passwd")
  25. 'passwd'
  26. >>> sanitize_filename("C:\\Windows\\System32")
  27. 'System32'
  28. >>> sanitize_filename("正常角色名")
  29. '正常角色名'
  30. 安全验证:
  31. - ✅ 防止目录遍历(../、..\\)
  32. - ✅ 防止绝对路径(/、C:\\)
  33. - ✅ 移除特殊字符
  34. - ✅ 长度限制
  35. """
  36. # Step 1: 仅保留基础文件名(移除所有路径)
  37. safe_name = os.path.basename(name)
  38. # Step 2: 移除路径分隔符(双重保险)
  39. safe_name = safe_name.replace('/', '_').replace('\\', '_')
  40. # Step 3: 只保留安全字符
  41. # 允许:中文(\u4e00-\u9fff)、字母(a-zA-Z)、数字(0-9)、下划线(_)、连字符(-)
  42. safe_name = re.sub(r'[^\w\u4e00-\u9fff-]', '_', safe_name)
  43. # Step 4: 移除连续的下划线(美化)
  44. safe_name = re.sub(r'_+', '_', safe_name)
  45. # Step 5: 长度限制
  46. if len(safe_name) > max_length:
  47. safe_name = safe_name[:max_length]
  48. # Step 6: 移除首尾下划线
  49. safe_name = safe_name.strip('_')
  50. # Step 7: 确保非空(防御性编程)
  51. if not safe_name:
  52. safe_name = "unnamed_entity"
  53. return safe_name
  54. def sanitize_commit_message(message: str, max_length: int = 200) -> str:
  55. """
  56. 清理Git提交消息,防止命令注入 (CWE-77)
  57. 安全关键函数 - 修复backup_manager.py命令注入漏洞
  58. Args:
  59. message: 原始提交消息(可能包含Git标志)
  60. max_length: 消息最大长度(默认200字符)
  61. Returns:
  62. 安全的提交消息(移除Git特殊标志和危险字符)
  63. 示例:
  64. >>> sanitize_commit_message("Test\\n--author='Attacker'")
  65. 'Test author Attacker'
  66. >>> sanitize_commit_message("--amend Chapter 1")
  67. 'amend Chapter 1'
  68. 安全验证:
  69. - ✅ 防止多行注入(换行符)
  70. - ✅ 防止Git标志注入(--xxx)
  71. - ✅ 防止参数分隔符混淆(引号)
  72. - ✅ 防止单字母标志(-x)
  73. """
  74. # Step 1: 移除换行符(防止多行参数注入)
  75. safe_msg = message.replace('\n', ' ').replace('\r', ' ')
  76. # Step 2: 移除Git特殊标志(--开头的参数)
  77. safe_msg = re.sub(r'--[\w-]+', '', safe_msg)
  78. # Step 3: 移除引号(防止参数分隔符混淆)
  79. safe_msg = safe_msg.replace("'", "").replace('"', '')
  80. # Step 4: 移除前导的-(防止单字母标志如-m)
  81. safe_msg = safe_msg.lstrip('-')
  82. # Step 5: 移除连续空格(美化)
  83. safe_msg = re.sub(r'\s+', ' ', safe_msg)
  84. # Step 6: 长度限制
  85. if len(safe_msg) > max_length:
  86. safe_msg = safe_msg[:max_length]
  87. # Step 7: 移除首尾空格
  88. safe_msg = safe_msg.strip()
  89. # Step 8: 确保非空
  90. if not safe_msg:
  91. safe_msg = "Untitled commit"
  92. return safe_msg
  93. def create_secure_directory(path: str, mode: int = 0o700) -> Path:
  94. """
  95. 创建安全目录(仅所有者可访问)
  96. 安全关键函数 - 修复文件权限配置缺失漏洞
  97. Args:
  98. path: 目录路径
  99. mode: 权限模式(默认0o700,仅所有者可读写执行)
  100. Returns:
  101. Path对象
  102. 示例:
  103. >>> create_secure_directory('.webnovel')
  104. PosixPath('.webnovel') # drwx------ (700)
  105. 安全验证:
  106. - ✅ 仅所有者可访问(0o700)
  107. - ✅ 防止同组用户读取
  108. - ✅ 跨平台兼容(Windows/Linux/macOS)
  109. """
  110. path_obj = Path(path)
  111. # 创建目录(设置安全权限)
  112. os.makedirs(path, mode=mode, exist_ok=True)
  113. # 双重保险:显式设置权限(某些系统可能忽略makedirs的mode参数)
  114. if os.name != 'nt': # Unix系统(Linux/macOS)
  115. os.chmod(path, mode)
  116. return path_obj
  117. def create_secure_file(file_path: str, content: str, mode: int = 0o600) -> None:
  118. """
  119. 创建安全文件(仅所有者可读写)
  120. Args:
  121. file_path: 文件路径
  122. content: 文件内容
  123. mode: 权限模式(默认0o600,仅所有者可读写)
  124. 安全验证:
  125. - ✅ 仅所有者可读写(0o600)
  126. - ✅ 防止其他用户访问
  127. """
  128. # 创建文件
  129. with open(file_path, 'w', encoding='utf-8') as f:
  130. f.write(content)
  131. # 设置权限(仅Unix系统)
  132. if os.name != 'nt':
  133. os.chmod(file_path, mode)
  134. def validate_integer_input(value: str, field_name: str) -> int:
  135. """
  136. 验证并转换整数输入(严格模式)
  137. 安全关键函数 - 修复update_state.py弱验证漏洞
  138. Args:
  139. value: 输入值(字符串)
  140. field_name: 字段名称(用于错误消息)
  141. Returns:
  142. 转换后的整数
  143. Raises:
  144. ValueError: 输入不是有效整数
  145. 示例:
  146. >>> validate_integer_input("123", "chapter_num")
  147. 123
  148. >>> validate_integer_input("abc", "level")
  149. ValueError: ❌ 错误:level 必须是整数,收到: abc
  150. """
  151. try:
  152. return int(value)
  153. except ValueError:
  154. print(f"❌ 错误:{field_name} 必须是整数,收到: {value}", file=sys.stderr)
  155. raise ValueError(f"Invalid integer input for {field_name}: {value}")
  156. # ============================================================================
  157. # 单元测试(内置自检)
  158. # ============================================================================
  159. def _run_self_tests():
  160. """运行内置安全测试"""
  161. print("🔍 运行安全工具函数自检...")
  162. # Test 1: sanitize_filename
  163. assert sanitize_filename("../../../etc/passwd") == "passwd", "路径遍历测试失败"
  164. assert sanitize_filename("C:\\Windows\\System32") == "System32", "Windows路径测试失败"
  165. assert sanitize_filename("正常角色名") == "正常角色名", "中文测试失败"
  166. assert sanitize_filename("/tmp/../../../../../etc/hosts") == "hosts", "复杂路径遍历测试失败"
  167. assert sanitize_filename("test///file...name") == "file_name", "特殊字符测试失败" # . 会被替换
  168. print(" ✅ sanitize_filename: 所有测试通过")
  169. # Test 2: sanitize_commit_message
  170. result = sanitize_commit_message("Test\n--author='Attacker'")
  171. assert "\n" not in result, "换行符未移除"
  172. assert "--author" not in result, "Git标志未移除"
  173. assert "Attacker" in result, "内容被错误移除"
  174. assert sanitize_commit_message("--amend Chapter 1") == "Chapter 1", "Git标志测试失败" # --amend被完全移除
  175. assert "'" not in sanitize_commit_message("Test'message"), "引号测试失败"
  176. assert sanitize_commit_message("-m Test") == "m Test", "单字母标志测试失败" # -m被移除后是"m Test"
  177. print(" ✅ sanitize_commit_message: 所有测试通过")
  178. # Test 3: validate_integer_input
  179. assert validate_integer_input("123", "test") == 123, "整数验证测试失败"
  180. try:
  181. validate_integer_input("abc", "test")
  182. assert False, "应该抛出ValueError"
  183. except ValueError:
  184. pass
  185. print(" ✅ validate_integer_input: 所有测试通过")
  186. print("\n✅ 所有安全工具函数测试通过!")
  187. if __name__ == "__main__":
  188. # Windows UTF-8 编码修复(必须在打印前执行)
  189. if sys.platform == 'win32':
  190. import io
  191. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
  192. sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
  193. # 运行自检测试
  194. _run_self_tests()