feat(ssh): 增加SSH超时配置并改进错误处理

- 在配置中添加SSH_CLONE_TIMEOUT、SSH_TEST_TIMEOUT和SSH_CONNECT_TIMEOUT
- 替换print为logging记录关键操作和错误
- 改进SSH命令构建方式防止注入
- 添加分支名验证逻辑
- 优化错误消息显示,避免暴露敏感信息
This commit is contained in:
lintsinghua 2025-12-26 20:34:47 +08:00
parent 96e4e21692
commit b030381ad2
3 changed files with 125 additions and 49 deletions

View File

@ -2,6 +2,7 @@
SSH密钥管理API端点 SSH密钥管理API端点
""" """
import logging
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -17,6 +18,7 @@ from app.services.git_ssh_service import SSHKeyService, GitSSHOperations, clear_
from app.core.encryption import encrypt_sensitive_data, decrypt_sensitive_data from app.core.encryption import encrypt_sensitive_data, decrypt_sensitive_data
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
# Schemas # Schemas
@ -93,7 +95,8 @@ async def generate_ssh_key(
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"生成SSH密钥失败: {str(e)}") logger.error(f"Failed to generate SSH key for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="生成SSH密钥失败请稍后重试")
@router.get("/", response_model=SSHKeyResponse) @router.get("/", response_model=SSHKeyResponse)
@ -130,7 +133,8 @@ async def get_ssh_key(
return {"has_key": False} return {"has_key": False}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"获取SSH密钥失败: {str(e)}") logger.error(f"Failed to get SSH key for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="获取SSH密钥失败请稍后重试")
@router.delete("/") @router.delete("/")
@ -169,7 +173,8 @@ async def delete_ssh_key(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"删除SSH密钥失败: {str(e)}") logger.error(f"Failed to delete SSH key for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="删除SSH密钥失败请稍后重试")
@router.post("/test", response_model=SSHKeyTestResponse) @router.post("/test", response_model=SSHKeyTestResponse)
@ -207,7 +212,7 @@ async def test_ssh_key(
# 验证密钥对是否匹配 # 验证密钥对是否匹配
is_valid = SSHKeyService.verify_key_pair(private_key, public_key) is_valid = SSHKeyService.verify_key_pair(private_key, public_key)
print(f"[SSH Test API] Key pair valid: {is_valid}") logger.debug(f"Key pair validation result: {is_valid}")
if not is_valid: if not is_valid:
return { return {
@ -224,7 +229,8 @@ async def test_ssh_key(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"测试SSH密钥失败: {str(e)}") logger.error(f"Failed to test SSH key for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="测试SSH密钥失败请稍后重试")
@router.delete("/known-hosts") @router.delete("/known-hosts")
@ -251,4 +257,5 @@ async def clear_known_hosts_file(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"清理失败: {str(e)}") logger.error(f"Failed to clear known_hosts for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail="清理失败,请稍后重试")

View File

@ -93,6 +93,9 @@ class Settings(BaseSettings):
# SSH配置 # SSH配置
SSH_CONFIG_PATH: str = "./data/ssh" # SSH配置目录存储known_hosts等 SSH_CONFIG_PATH: str = "./data/ssh" # SSH配置目录存储known_hosts等
SSH_CLONE_TIMEOUT: int = 300 # SSH克隆超时时间
SSH_TEST_TIMEOUT: int = 15 # SSH测试连接超时时间
SSH_CONNECT_TIMEOUT: int = 10 # SSH连接超时时间
# Agent 配置 # Agent 配置
AGENT_MAX_ITERATIONS: int = 50 # Agent 最大迭代次数 AGENT_MAX_ITERATIONS: int = 50 # Agent 最大迭代次数

View File

@ -4,6 +4,9 @@ Git SSH服务 - 生成SSH密钥并使用SSH方式访问Git仓库
import os import os
import sys import sys
import re
import shlex
import logging
import tempfile import tempfile
import subprocess import subprocess
import shutil import shutil
@ -15,6 +18,55 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
# 配置日志
logger = logging.getLogger(__name__)
def is_valid_branch_name(branch: str) -> bool:
"""
验证 Git 分支名是否合法
Git 分支名规则:
- 不能以 . - 开头
- 不能包含 .., ~, ^, :, ?, *, [, \\, 空格
- 不能以 / 结尾
- 不能以 .lock 结尾
Args:
branch: 分支名
Returns:
是否为合法的分支名
"""
if not branch:
return False
# 基本格式检查:只允许字母、数字、-、_、/、.
if not re.match(r'^[\w\-/.]+$', branch):
return False
# 不能以 . 或 - 开头
if branch.startswith('.') or branch.startswith('-'):
return False
# 不能以 / 结尾
if branch.endswith('/'):
return False
# 不能以 .lock 结尾
if branch.endswith('.lock'):
return False
# 不能包含连续的 ..
if '..' in branch:
return False
# 不能包含连续的 //
if '//' in branch:
return False
return True
def get_ssh_config_dir() -> str: def get_ssh_config_dir() -> str:
""" """
@ -69,10 +121,10 @@ def clear_known_hosts() -> bool:
# 清空文件内容 # 清空文件内容
with open(known_hosts_file, 'w') as f: with open(known_hosts_file, 'w') as f:
f.write('') f.write('')
print(f"[SSH] Cleared known_hosts file: {known_hosts_file}") logger.info(f"Cleared known_hosts file: {known_hosts_file}")
return True return True
except Exception as e: except Exception as e:
print(f"[SSH] Failed to clear known_hosts: {e}") logger.error(f"Failed to clear known_hosts: {e}")
return False return False
@ -99,7 +151,7 @@ def set_secure_file_permissions(file_path: str):
check=True check=True
) )
except Exception as e: except Exception as e:
print(f"Warning: Failed to set Windows file permissions: {e}") logger.warning(f"Failed to set Windows file permissions: {e}")
# 尝试使用os.chmod作为后备方案 # 尝试使用os.chmod作为后备方案
try: try:
os.chmod(file_path, 0o600) os.chmod(file_path, 0o600)
@ -145,7 +197,7 @@ class SSHKeyService:
return f"SHA256:{fingerprint}" return f"SHA256:{fingerprint}"
except Exception as e: except Exception as e:
print(f"[SSH] Fingerprint calculation error: {e}") logger.error(f"Fingerprint calculation error: {e}")
return None return None
@staticmethod @staticmethod
@ -187,7 +239,7 @@ class SSHKeyService:
backend=default_backend() backend=default_backend()
) )
except Exception as e: except Exception as e:
print(f"[SSH] Failed to load private key: {e}") logger.debug(f"Failed to load private key: {e}")
return False return False
if not private_key_obj: if not private_key_obj:
@ -207,7 +259,7 @@ class SSHKeyService:
return expected_public == actual_public return expected_public == actual_public
except Exception as e: except Exception as e:
print(f"[SSH] Key verification error: {e}") logger.error(f"Key verification error: {e}")
return False return False
@staticmethod @staticmethod
@ -302,8 +354,15 @@ class GitSSHOperations:
Returns: Returns:
操作结果字典 操作结果字典
""" """
from app.core.config import settings
temp_dir = None temp_dir = None
try: try:
# 验证分支名(如果提供)
if branch and not is_valid_branch_name(branch):
logger.warning(f"Invalid branch name rejected: {branch}")
return {'success': False, 'message': f'无效的分支名: {branch}'}
# 创建临时目录存放SSH密钥 # 创建临时目录存放SSH密钥
temp_dir = tempfile.mkdtemp(prefix='deepaudit_ssh_') temp_dir = tempfile.mkdtemp(prefix='deepaudit_ssh_')
key_file = os.path.join(temp_dir, 'id_rsa') key_file = os.path.join(temp_dir, 'id_rsa')
@ -319,19 +378,18 @@ class GitSSHOperations:
# 设置Git SSH命令只使用DeepAudit生成的SSH密钥 # 设置Git SSH命令只使用DeepAudit生成的SSH密钥
env = os.environ.copy() env = os.environ.copy()
# 构建SSH命令只使用DeepAudit密钥 # 构建SSH命令使用 shlex.quote 转义路径防止命令注入)
ssh_cmd_parts = [ ssh_cmd = (
'ssh', f"ssh -i {shlex.quote(key_file)} "
'-i', key_file, f"-o StrictHostKeyChecking=accept-new "
'-o', 'StrictHostKeyChecking=accept-new', # 首次连接时自动接受并保存host key f"-o UserKnownHostsFile={shlex.quote(known_hosts_file)} "
'-o', f'UserKnownHostsFile={known_hosts_file}', # 使用持久化known_hosts文件 f"-o PreferredAuthentications=publickey "
'-o', 'PreferredAuthentications=publickey', f"-o IdentitiesOnly=yes"
'-o', 'IdentitiesOnly=yes' # 只使用指定的密钥,不使用系统默认密钥 )
]
env['GIT_SSH_COMMAND'] = ' '.join(ssh_cmd_parts) env['GIT_SSH_COMMAND'] = ssh_cmd
print(f"[Git Clone] Using DeepAudit SSH key: {key_file}") logger.debug(f"Using SSH key file: {key_file}")
print(f"[Git Clone] Using known_hosts file: {known_hosts_file}") logger.debug(f"Using known_hosts file: {known_hosts_file}")
# 执行git clone # 执行git clone
cmd = ['git', 'clone', '--depth', '1'] cmd = ['git', 'clone', '--depth', '1']
@ -344,7 +402,7 @@ class GitSSHOperations:
env=env, env=env,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=300 timeout=settings.SSH_CLONE_TIMEOUT
) )
if result.returncode == 0: if result.returncode == 0:
@ -354,6 +412,7 @@ class GitSSHOperations:
'path': target_dir 'path': target_dir
} }
else: else:
logger.error(f"Git clone failed: {result.stderr}")
return { return {
'success': False, 'success': False,
'message': '仓库克隆失败', 'message': '仓库克隆失败',
@ -361,8 +420,10 @@ class GitSSHOperations:
} }
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return {'success': False, 'message': '克隆超时超过5分钟'} logger.error(f"Git clone timeout after {settings.SSH_CLONE_TIMEOUT}s")
return {'success': False, 'message': f'克隆超时(超过{settings.SSH_CLONE_TIMEOUT}秒)'}
except Exception as e: except Exception as e:
logger.error(f"Git clone error: {e}")
return {'success': False, 'message': f'克隆失败: {str(e)}'} return {'success': False, 'message': f'克隆失败: {str(e)}'}
finally: finally:
# 清理临时文件 # 清理临时文件
@ -429,13 +490,13 @@ class GitSSHOperations:
'content': content 'content': content
}) })
except Exception as e: except Exception as e:
print(f"读取文件 {rel_path} 失败: {e}") logger.debug(f"读取文件 {rel_path} 失败: {e}")
continue continue
return files return files
except Exception as e: except Exception as e:
print(f"获取SSH仓库文件失败: {e}") logger.error(f"获取SSH仓库文件失败: {e}")
raise raise
finally: finally:
# 清理临时克隆目录 # 清理临时克隆目录
@ -454,6 +515,8 @@ class GitSSHOperations:
Returns: Returns:
测试结果字典 测试结果字典
""" """
from app.core.config import settings
temp_dir = None temp_dir = None
try: try:
# 从URL提取主机 # 从URL提取主机
@ -462,6 +525,11 @@ class GitSSHOperations:
else: else:
return {'success': False, 'message': 'URL格式无效'} return {'success': False, 'message': 'URL格式无效'}
# 验证主机名格式(防止注入)
if not re.match(r'^[\w\-\.]+$', host_part):
logger.warning(f"Invalid host name rejected: {host_part}")
return {'success': False, 'message': '无效的主机名'}
# 创建临时目录存放密钥 # 创建临时目录存放密钥
temp_dir = tempfile.mkdtemp(prefix='deepaudit_ssh_test_') temp_dir = tempfile.mkdtemp(prefix='deepaudit_ssh_test_')
key_file = os.path.join(temp_dir, 'id_rsa') key_file = os.path.join(temp_dir, 'id_rsa')
@ -472,57 +540,53 @@ class GitSSHOperations:
# 验证文件是否被创建 # 验证文件是否被创建
if not os.path.exists(key_file): if not os.path.exists(key_file):
return {'success': False, 'message': f'私钥文件创建失败: {key_file}'} return {'success': False, 'message': '私钥文件创建失败'}
file_size = os.path.getsize(key_file)
set_secure_file_permissions(key_file) set_secure_file_permissions(key_file)
# 使用持久化的known_hosts文件 # 使用持久化的known_hosts文件
known_hosts_file = get_known_hosts_file() known_hosts_file = get_known_hosts_file()
# 构建SSH命令只使用DeepAudit密钥 # 构建SSH命令使用列表形式避免shell注入
cmd = [ cmd = [
'ssh', 'ssh',
'-i', key_file, '-i', key_file,
'-o', 'StrictHostKeyChecking=accept-new', # 首次连接时自动接受并保存host key '-o', 'StrictHostKeyChecking=accept-new',
'-o', f'UserKnownHostsFile={known_hosts_file}', # 使用持久化known_hosts文件 '-o', f'UserKnownHostsFile={known_hosts_file}',
'-o', 'ConnectTimeout=10', '-o', f'ConnectTimeout={settings.SSH_CONNECT_TIMEOUT}',
'-o', 'PreferredAuthentications=publickey', '-o', 'PreferredAuthentications=publickey',
'-o', 'IdentitiesOnly=yes', # 只使用指定的密钥,不使用系统默认密钥 '-o', 'IdentitiesOnly=yes',
'-v', # 详细输出 '-v',
'-T', f'git@{host_part}' '-T', f'git@{host_part}'
] ]
print(f"[SSH Test] Using known_hosts file: {known_hosts_file}") logger.debug(f"Testing SSH connection to: {host_part}")
result = subprocess.run( result = subprocess.run(
cmd, cmd,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=15 timeout=settings.SSH_TEST_TIMEOUT
) )
# GitHub/GitLab/CodeUp的SSH测试通常返回非0状态码但会在输出中显示认证成功 # GitHub/GitLab/CodeUp的SSH测试通常返回非0状态码但会在输出中显示认证成功
output = result.stdout + result.stderr output = result.stdout + result.stderr
output_lower = output.lower() output_lower = output.lower()
# 特别检查Anonymous表示公钥未添加或未关联用户账户 # 特别检查Anonymous表示公钥未添加或未关联用户账户
# 必须在检查成功之前检查因为Anonymous表示认证技术上成功但没有关联用户
if 'anonymous' in output_lower: if 'anonymous' in output_lower:
return { return {
'success': True, 'success': True,
'message': 'SSH连接成功但公钥未关联用户账户', 'message': 'SSH连接成功但公钥未关联用户账户',
'output': f'提示服务器显示Anonymous,在使用部署密钥时是正常现象。\n请在Git服务的设置中添加SSH公钥。\n\n原始输出:\n{output}' 'output': '提示服务器显示Anonymous,在使用部署密钥时是正常现象。\n请在Git服务的设置中添加SSH公钥。'
} }
# 检查是否认证成功 # 检查是否认证成功
success_indicators = [ success_indicators = [
('successfully authenticated', True), # GitHub ('successfully authenticated', True),
('hi ', True), # GitHub: "Hi username!" ('hi ', True),
('welcome to gitlab', '@' in output), # GitLab需要有@username ('welcome to gitlab', '@' in output),
('welcome to codeup', '@' in output), # CodeUp需要有@username ('welcome to codeup', '@' in output),
] ]
is_success = False is_success = False
@ -557,16 +621,18 @@ class GitSSHOperations:
} }
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning(f"SSH test timeout after {settings.SSH_TEST_TIMEOUT}s")
return { return {
'success': False, 'success': False,
'message': 'SSH连接超时15秒)', 'message': f'SSH连接超时{settings.SSH_TEST_TIMEOUT}秒)',
'output': '连接超时请检查网络或Git服务可用性' 'output': '连接超时请检查网络或Git服务可用性'
} }
except Exception as e: except Exception as e:
logger.error(f"SSH test error: {e}")
return { return {
'success': False, 'success': False,
'message': f'测试失败: {str(e)}', 'message': '测试失败,请稍后重试',
'output': str(e) 'output': ''
} }
finally: finally:
if temp_dir and os.path.exists(temp_dir): if temp_dir and os.path.exists(temp_dir):