- 新增安全评估报告与P0修复方案(IDOR水平越权~29接口 + 全局LLM脱敏) - 新增安全开发规范v1.1(9大安全规范 + Code Review检查清单) - 采纳架构师审查意见补充OSS签名URL、导出审计、接口限流、依赖安全 - 更新开发规范README索引 Made-with: Cursor
886 lines
32 KiB
Markdown
886 lines
32 KiB
Markdown
# AI临床研究平台 安全评估报告与修复方案
|
||
|
||
> **文档类型**: 安全评估报告 + 漏洞修复方案
|
||
> **创建日期**: 2026-03-02
|
||
> **评估目标**: 北京市卫健委信息处安全审查准备
|
||
> **评估范围**: 代码安全、架构安全、阿里云 SAE 部署安全、数据安全与隐私合规
|
||
> **预计修复总工时**: P0 约 2-3 天,P1 约 1 周,P2 约 2 周
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [第一部分:安全问题总览](#第一部分安全问题总览)
|
||
- [第二部分:当前系统已有的安全措施(可用于汇报)](#第二部分当前系统已有的安全措施可用于汇报)
|
||
- [第三部分:P0 详细修复方案 — IDOR 水平越权](#第三部分p0-详细修复方案--idor-水平越权)
|
||
- [第四部分:P0 详细修复方案 — 全局 LLM 脱敏](#第四部分p0-详细修复方案--全局-llm-脱敏)
|
||
- [第五部分:分阶段安全加固路线图](#第五部分分阶段安全加固路线图)
|
||
- [第六部分:阿里云安全产品采购建议](#第六部分阿里云安全产品采购建议)
|
||
- [第七部分:安全架构总览(当前 vs 目标)](#第七部分安全架构总览当前-vs-目标)
|
||
- [附录 A:LLM 调用点完整清单](#附录-allm-调用点完整清单)
|
||
- [附录 B:安全制度文档编写清单](#附录-b安全制度文档编写清单)
|
||
|
||
---
|
||
|
||
# 第一部分:安全问题总览
|
||
|
||
## P0 — 致命风险(必须立即修复)
|
||
|
||
### 1. 生产环境全部凭据明文写在 Git 仓库的文档中
|
||
|
||
**这是最严重的安全问题。** `docs/05-部署文档/00-阿里云SAE最新真实状态记录.md` 中包含:
|
||
|
||
- RDS 数据库密码
|
||
- ACR 镜像仓库密码
|
||
- OSS AccessKey / Secret
|
||
- JWT Secret(完整 64 位)
|
||
- 所有 LLM API Key(DeepSeek、DashScope、CloseAI、Unifuncs)
|
||
- 企业微信 Corp Secret
|
||
- 旧系统 MySQL 连接信息(IP + 用户名)
|
||
|
||
**影响**:任何有 Git 仓库访问权限的人都能获取全部生产凭据。即使仓库是私有的,Git 历史会永久保留这些信息。
|
||
|
||
**修复**:
|
||
- 立即将该文件从 Git 跟踪中移除,凭据部分改为引用(如"见阿里云控制台")
|
||
- **轮换所有已暴露的密钥**(OSS AK/SK、JWT Secret、所有 API Key、数据库密码等)
|
||
- 使用 `git filter-branch` 或 BFG Repo-Cleaner 清理 Git 历史
|
||
|
||
### 2. `.env.example` 中包含疑似真实 API Key
|
||
|
||
文件 `backend/.env.example` 中:
|
||
|
||
```
|
||
DEEPSEEK_API_KEY=sk-7f8cc37a79fa4799860b38fc7ba2e150
|
||
DASHSCOPE_API_KEY=sk-75b4ff29a14a49e79667a331034f3298
|
||
```
|
||
|
||
示例文件应使用占位符(如 `your-key-here`),不应包含真实密钥。
|
||
|
||
### 3. 多处脚本/测试代码硬编码 API Key 和密码
|
||
|
||
- `backend/src/modules/legacy-bridge/mysql-pool.ts:15` — MySQL 密码硬编码
|
||
- `backend/scripts/test-unifuncs-clinicaltrials.ts:11` — Unifuncs API Key 硬编码
|
||
- `backend/scripts/test-unifuncs-site-coverage.ts:13` — 同上
|
||
- `backend/src/modules/asl/__tests__/deep-research-chinese-sources.ts:10` — 同上
|
||
|
||
### 4. SQL 注入漏洞
|
||
|
||
- `backend/src/modules/admin/iit-projects/iitQcCockpitController.ts` 第 211-214、233、244、301、543-556 行:`dateFilter`、`recordId`、`eventId`、`status`、`pageSize`、`offset` 等 query 参数**直接拼接到原始 SQL**,未使用参数化查询
|
||
- `backend/src/modules/pkb/services/VectorSearchService.ts` 第 125-165 行:`kbId`、`documentIds` 等仅做单引号转义,仍存在注入风险
|
||
|
||
### 5. Legacy API 无认证保护
|
||
|
||
`backend/src/index.ts` 中以下路由**完全没有 `authenticate` 中间件**:
|
||
|
||
- `/api/v1` 下的 `projectRoutes`、`agentRoutes`、`conversationRoutes`、`knowledgeBaseRoutes`
|
||
- `/test/platform` — 暴露存储、缓存、队列等内部信息
|
||
|
||
任何人可以无需认证访问这些接口,读写数据。
|
||
|
||
### 6. IDOR 水平越权漏洞(~29 个接口)
|
||
|
||
系统在处理资源查询、下载、操作时,部分模块**只校验了 Token 有效性,未校验资源是否属于当前用户**。攻击者遍历 ID 即可越权访问全站数据。
|
||
|
||
| 模块 | 受影响接口数 | 风险等级 | 涉及数据 |
|
||
|------|------------|---------|---------|
|
||
| SSA 智能统计分析 | ~12 | 高 | 用户上传的研究数据、分析代码、SAP 文档 |
|
||
| DC Tool-C 数据清洗 | ~8 | 高 | 用户上传的 Excel/CSV 临床数据 |
|
||
| ASL 文献提取 | ~5 | 高 | 提取结果、任务状态、导出文件 |
|
||
| IIT CRA 质控 | ~4 | 中 | 跨租户项目数据(药企/医院) |
|
||
|
||
> 详细修复方案见 [第三部分](#第三部分p0-详细修复方案--idor-水平越权)
|
||
|
||
### 7. LLM 调用普遍缺少 PII 脱敏
|
||
|
||
仅 DC 模块的 `DualModelExtractionService` 实现了 `maskPII()`。以下模块直接将原文发送给第三方 LLM:
|
||
|
||
- RVW:稿件全文(可能含作者信息)
|
||
- ASL:文献全文
|
||
- PKB:用户文档全文
|
||
- IIT:REDCap 记录(可能含患者信息)
|
||
- SSA:用户上传的研究数据
|
||
|
||
**卫健委审查重点**:临床研究数据发送给外部 LLM 服务是否合规?
|
||
|
||
> 详细修复方案见 [第四部分](#第四部分p0-详细修复方案--全局-llm-脱敏)
|
||
|
||
---
|
||
|
||
## P1 — 高风险(一周内修复)
|
||
|
||
### 8. CORS 配置允许任意来源
|
||
|
||
`backend/src/index.ts` 第 59 行:`origin: true`,允许任意域名跨域请求。代码中有 `config.corsOrigin` 配置项但未生效。
|
||
|
||
**修复**:生产环境改为白名单 `origin: ['https://iit.xunzhengyixue.com']`
|
||
|
||
### 9. 无速率限制(Rate Limiting)
|
||
|
||
登录、验证码发送、API 调用等均无速率限制,存在:
|
||
|
||
- 暴力破解密码风险
|
||
- 短信验证码轰炸风险(虽有 1 分钟间隔,但无 IP 级限制)
|
||
- API 滥用风险
|
||
|
||
### 10. 验证码明文写入日志
|
||
|
||
`backend/src/common/auth/auth.service.ts` 第 419-421 行:
|
||
|
||
```typescript
|
||
logger.info('验证码已生成', { phone, code, type, expiresAt });
|
||
console.log(`验证码: ${code}`);
|
||
```
|
||
|
||
生产环境日志会被收集到 SLS,验证码可被任何有日志权限的人看到。
|
||
|
||
### 11. 前端 Token 存储在 localStorage
|
||
|
||
`frontend-v2/src/framework/auth/api.ts` 使用 `localStorage` 存储 accessToken 和 refreshToken,XSS 攻击可直接读取。
|
||
|
||
### 12. Nginx 缺少安全响应头
|
||
|
||
`frontend-v2/nginx.conf` 未配置:
|
||
|
||
- `X-Frame-Options`(防点击劫持)
|
||
- `X-Content-Type-Options`(防 MIME 嗅探)
|
||
- `Content-Security-Policy`(防 XSS)
|
||
- `Strict-Transport-Security`(强制 HTTPS)
|
||
|
||
### 13. 无登录失败锁定机制
|
||
|
||
密码错误可无限次尝试,无任何锁定策略。
|
||
|
||
### 14. 密码强度要求过低
|
||
|
||
`auth.service.ts` 第 322 行:仅要求长度 >= 6,无大小写、数字、特殊字符要求。
|
||
|
||
### 15. CLB 负载均衡未配置 HTTPS
|
||
|
||
部署文档显示 CLB 仅监听 80 端口(HTTP),用户数据在公网以明文传输。
|
||
|
||
---
|
||
|
||
## P2 — 中风险(两周内修复)
|
||
|
||
### 16. 用户 PII 明文存储
|
||
|
||
数据库 `users` 表中 `phone`(手机号)、`name`(姓名)、`email`(邮箱)均明文存储,未做列级加密或应用层加密。
|
||
|
||
### 17. 多租户数据隔离不完整
|
||
|
||
- `requireSameTenant` 中间件仅在显式传 `tenantId` 时生效
|
||
- 多数业务接口只依赖 `userId`,未强制校验 `tenantId`
|
||
- 业务数据表(Conversation、Document 等)通过 `userId` 关联,未在 Schema 层绑定 `tenant_id`
|
||
|
||
### 18. 管理员操作审计日志未启用
|
||
|
||
数据库中已有 `admin_operation_logs` 表,但代码中未找到写入逻辑。管理员的增删改操作无审计追踪。
|
||
|
||
### 19. 登录日志缺少关键字段
|
||
|
||
当前登录日志不包含 IP 地址和 User-Agent,无法做异常登录分析。
|
||
|
||
### 20. extraction_service 容器以 root 运行
|
||
|
||
`extraction_service/Dockerfile` 未创建非 root 用户,不符合最小权限原则。
|
||
|
||
### 21. 错误响应泄露系统信息
|
||
|
||
- `ExtractionController.ts` 第 152 行:开发环境返回 error.stack
|
||
- `FulltextScreeningController.ts` 第 301-304 行:返回 `task.errorStack`
|
||
- 多个 Controller 返回 `error.message`,可能暴露内部实现
|
||
|
||
### 22. 数据库连接未使用 SSL
|
||
|
||
`DATABASE_URL` 中未配置 `sslmode=require`,数据库连接在网络层面可能被截获。
|
||
|
||
### 23. 无自动化定期备份
|
||
|
||
当前依赖手工 `pg_dump`,根目录有多个 `.dump` 文件。RDS 有每日自动备份,但无应用层的定期备份策略。
|
||
|
||
---
|
||
|
||
# 第二部分:当前系统已有的安全措施(可用于汇报)
|
||
|
||
## 认证与授权
|
||
|
||
- JWT 认证体系(Access Token 2h + Refresh Token 7d)
|
||
- 基于角色的访问控制(RBAC):SUPER_ADMIN / PROMPT_ENGINEER / ORG_ADMIN / USER
|
||
- 模块级权限控制:`requireModule` 中间件
|
||
- 单设备登录机制:`tokenVersion` + 缓存实现踢人
|
||
- 密码使用 bcrypt 加密存储(rounds=10)
|
||
|
||
## 网络与基础设施
|
||
|
||
- 阿里云 VPC 网络隔离(172.17.0.0/16)
|
||
- RDS 白名单限制:仅 VPC 网段可访问
|
||
- RDS 外网地址已关闭
|
||
- SAE 服务间通信走内网
|
||
- 后端 Dockerfile 使用非 root 用户(nodejs:1001)
|
||
- 多阶段 Docker 构建,减少攻击面
|
||
- Nginx `server_tokens off`,隐藏版本信息
|
||
- Nginx 禁止访问隐藏文件和敏感后缀
|
||
|
||
## 数据存储
|
||
|
||
- OSS 使用私有 Bucket + 签名 URL 访问
|
||
- OSS 通过内网 Endpoint 访问,减少数据泄露风险
|
||
- OSS 使用 HTTPS(`secure: true`)
|
||
- 文件上传有 MIME 类型和大小限制
|
||
- 文件存储使用 UUID 命名,原始文件名存数据库
|
||
- 多 Schema 数据隔离(16 个独立 Schema)
|
||
|
||
## 应用安全
|
||
|
||
- Prisma ORM 参数化查询(大部分场景)
|
||
- `.env` 文件在 `.gitignore` 中
|
||
- 生产环境 JWT Secret 强制不使用默认值
|
||
- Fastify 框架内置安全特性
|
||
- 运营行为日志(simple_logs)
|
||
- RDS 自动备份 + PITR(每日凌晨 2 点,保留 7 天)
|
||
|
||
---
|
||
|
||
# 第三部分:P0 详细修复方案 — IDOR 水平越权
|
||
|
||
## 3.1 问题描述
|
||
|
||
系统中部分模块在处理资源查询、下载、操作时,**只校验了用户是否登录(Token 有效)**,未校验目标资源是否属于当前登录用户或其所在租户。攻击者只需遍历资源 ID,即可越权访问全站数据。
|
||
|
||
已正确实现权限校验的模块(PKB、RVW、AIA)证明项目中有成熟的做法可参考,问题出在各模块开发节奏不一致,未统一执行安全规范。
|
||
|
||
## 3.2 已做好权限校验的模块(正面参考)
|
||
|
||
以下模块已正确实现 `userId` 归属校验,是修复其他模块的参考模板:
|
||
|
||
**PKB 知识库** — `documentService.getDocumentById(userId, id)`:
|
||
|
||
```typescript
|
||
// backend/src/modules/pkb/services/documentService.ts 第 202-206 行
|
||
const document = await prisma.document.findFirst({
|
||
where: {
|
||
id: documentId,
|
||
userId, // ✅ 强制校验归属
|
||
},
|
||
```
|
||
|
||
**RVW 审稿** — `reviewService.getTaskDetail(userId, taskId)`:
|
||
|
||
```typescript
|
||
// backend/src/modules/rvw/services/reviewService.ts 第 386-388 行
|
||
const task = await prisma.reviewTask.findFirst({
|
||
where: { id: taskId, userId }, // ✅ 强制校验归属
|
||
});
|
||
```
|
||
|
||
**AIA 对话** — `conversationService.getConversationById(userId, id)`:
|
||
|
||
```typescript
|
||
// backend/src/modules/aia/services/conversationService.ts 第 134-136 行
|
||
const conversation = await prisma.conversation.findFirst({
|
||
where: { id: conversationId, userId }, // ✅ 强制校验归属
|
||
});
|
||
```
|
||
|
||
## 3.3 漏洞详情与修复方案
|
||
|
||
### 3.3.1 SSA 智能统计分析模块(~12 个接口,优先级最高)
|
||
|
||
**问题根源**:所有 `ssaSession` 查询仅凭 `id`,未加 `userId` 条件。
|
||
|
||
**受影响文件与代码**:
|
||
|
||
**(1) session.routes.ts** — 会话详情 / 消息 / 执行模式
|
||
|
||
```typescript
|
||
// backend/src/modules/ssa/routes/session.routes.ts 第 126-129 行
|
||
const session = await prisma.ssaSession.findUnique({
|
||
where: { id }, // ❌ 缺少 userId
|
||
include: { messages: true }
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// 第 149 行
|
||
const session = await prisma.ssaSession.findUnique({ where: { id } }); // ❌
|
||
```
|
||
|
||
```typescript
|
||
// 第 166-173 行 — 消息历史
|
||
const messages = await prisma.ssaMessage.findMany({
|
||
where: { sessionId: id }, // ❌ 未校验 session 归属
|
||
orderBy: { createdAt: 'asc' }
|
||
});
|
||
```
|
||
|
||
**(2) analysis.routes.ts** — 上传 / 计划 / 执行 / 下载代码
|
||
|
||
```typescript
|
||
// 第 54-58 行 — upload
|
||
await prisma.ssaSession.update({
|
||
where: { id }, // ❌ 缺少 userId
|
||
data: { dataOssKey: storageKey }
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// 第 73-77 行 — plan
|
||
const session = await prisma.ssaSession.findUnique({
|
||
where: { id }, // ❌
|
||
select: { dataSchema: true }
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// 第 216-219 行 — execute
|
||
const session = await prisma.ssaSession.findUnique({
|
||
where: { id } // ❌
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// 第 334-338 行 — download-code
|
||
const session = await prisma.ssaSession.findUnique({
|
||
where: { id }, // ❌
|
||
select: { title: true, createdAt: true }
|
||
});
|
||
```
|
||
|
||
**(3) consult.routes.ts** — 咨询聊天 / 生成 SAP / 下载 SAP
|
||
|
||
```typescript
|
||
// 第 42-55 行 — chat:直接用 id 写入消息,未校验 session 归属
|
||
app.post('/:id/chat', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
await prisma.ssaMessage.create({
|
||
data: { sessionId: id, ... } // ❌ 未校验 session.userId
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// 第 142 行 — generate-sap / 第 168 行 — download-sap
|
||
// 同样未校验 session 归属
|
||
```
|
||
|
||
**(4) chat.routes.ts** — 对话历史 / 会话元信息(第 207-253 行)
|
||
|
||
**修复方案**:
|
||
|
||
```typescript
|
||
// 统一修复模式:所有 ssaSession 查询加 userId
|
||
const userId = getUserId(request);
|
||
|
||
const session = await prisma.ssaSession.findFirst({
|
||
where: { id: sessionId, userId },
|
||
});
|
||
if (!session) {
|
||
return reply.status(404).send({ error: '会话不存在' });
|
||
}
|
||
```
|
||
|
||
对于 `ssaMessage` 的查询,先校验 session 归属,再查消息:
|
||
|
||
```typescript
|
||
const session = await prisma.ssaSession.findFirst({
|
||
where: { id: sessionId, userId },
|
||
});
|
||
if (!session) return reply.status(404).send({ error: '会话不存在' });
|
||
|
||
const messages = await prisma.ssaMessage.findMany({
|
||
where: { sessionId },
|
||
orderBy: { createdAt: 'asc' }
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### 3.3.2 DC Tool-C 数据清洗模块(~8 个接口)
|
||
|
||
**问题根源**:`SessionService` 方法只接受 `sessionId`,不接受 `userId`。
|
||
|
||
**受影响文件**:
|
||
|
||
- `backend/src/modules/dc/tool-c/controllers/SessionController.ts`(第 126-136 行)
|
||
- `backend/src/modules/dc/tool-c/services/SessionService.ts`(第 148-155 行)
|
||
|
||
```typescript
|
||
// SessionController.ts 第 131 行
|
||
const session = await sessionService.getSession(id); // ❌ 未传 userId
|
||
|
||
// SessionService.ts 第 152-154 行
|
||
const session = await prisma.dcToolCSession.findUnique({
|
||
where: { id: sessionId }, // ❌ 缺少 userId
|
||
});
|
||
```
|
||
|
||
受影响接口:`getSession`、`getPreviewData`、`getFullData`、`deleteSession`、`exportData`、`heartbeat`、`getUniqueValues`、`getStatus`
|
||
|
||
**修复方案**:
|
||
|
||
```typescript
|
||
// SessionService — 所有方法增加 userId 参数
|
||
async getSession(sessionId: string, userId: string): Promise<SessionData> {
|
||
const session = await prisma.dcToolCSession.findFirst({
|
||
where: { id: sessionId, userId },
|
||
});
|
||
if (!session) throw new NotFoundError('会话不存在');
|
||
// ...
|
||
}
|
||
|
||
// SessionController — 从 request 取 userId 传入
|
||
const userId = getUserId(request);
|
||
const session = await sessionService.getSession(id, userId);
|
||
```
|
||
|
||
---
|
||
|
||
### 3.3.3 ASL 文献提取模块(~5 个接口)
|
||
|
||
**问题根源**:Controller 直接用 `taskId`/`resultId` 查询,未校验 `task.userId`。
|
||
|
||
**受影响文件**:
|
||
|
||
- `backend/src/modules/asl/extraction/controllers/ExtractionController.ts`
|
||
- `backend/src/modules/asl/extraction/services/ExtractionService.ts`
|
||
|
||
```typescript
|
||
// ExtractionController.ts 第 78-84 行
|
||
export async function getTaskStatus(request, reply) {
|
||
const status = await extractionService.getTaskStatus(request.params.taskId); // ❌
|
||
}
|
||
|
||
// ExtractionService.ts 第 118-122 行
|
||
async getTaskStatus(taskId: string) {
|
||
const task = await prisma.aslExtractionTask.findUnique({
|
||
where: { id: taskId }, // ❌ 缺少 userId
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// ExtractionController.ts 第 119-136 行 — getResultDetail
|
||
const result = await prisma.aslExtractionResult.findUnique({
|
||
where: { id: request.params.resultId }, // ❌ 未校验 result → task → userId
|
||
include: { task: { ... } },
|
||
});
|
||
```
|
||
|
||
受影响接口:`getTaskStatus`、`getResults`、`getResultDetail`、`reviewResult`、`exportTaskResults`、`listDocuments`
|
||
|
||
**修复方案**:
|
||
|
||
```typescript
|
||
// ExtractionService — 增加 userId 参数
|
||
async getTaskStatus(taskId: string, userId: string) {
|
||
const task = await prisma.aslExtractionTask.findFirst({
|
||
where: { id: taskId, userId },
|
||
});
|
||
if (!task) throw Object.assign(new Error('任务不存在'), { statusCode: 404 });
|
||
// ...
|
||
}
|
||
|
||
// getResultDetail — 通过关联 task 校验归属
|
||
const result = await prisma.aslExtractionResult.findFirst({
|
||
where: {
|
||
id: resultId,
|
||
task: { userId },
|
||
},
|
||
include: { task: { ... } },
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### 3.3.4 IIT CRA 质控模块(~4 个接口,租户隔离)
|
||
|
||
**问题根源**:项目查询仅凭 `id`,未按 `tenantId` 过滤,PHARMA_ADMIN / HOSPITAL_ADMIN 可能跨租户访问。
|
||
|
||
**受影响文件**:
|
||
|
||
- `backend/src/modules/admin/iit-projects/iitProjectService.ts`(第 116-132 行)
|
||
- `backend/src/modules/admin/iit-projects/iitBatchController.ts`
|
||
- `backend/src/modules/admin/iit-projects/iitQcCockpitController.ts`
|
||
|
||
```typescript
|
||
// iitProjectService.ts 第 117-118 行
|
||
async getProject(id: string) {
|
||
const project = await this.prisma.iitProject.findFirst({
|
||
where: { id, deletedAt: null }, // ❌ 缺少 tenantId
|
||
```
|
||
|
||
**修复方案**:
|
||
|
||
```typescript
|
||
async getProject(id: string, tenantId?: string) {
|
||
const project = await this.prisma.iitProject.findFirst({
|
||
where: { id, tenantId, deletedAt: null },
|
||
});
|
||
// SUPER_ADMIN 可传 tenantId = undefined 跳过租户校验
|
||
```
|
||
|
||
## 3.4 注意事项
|
||
|
||
| 场景 | 处理方式 |
|
||
|------|---------|
|
||
| SUPER_ADMIN 需要查看所有用户数据 | 在 Service 方法中,若 role 为 SUPER_ADMIN 则跳过 userId/tenantId 校验 |
|
||
| Worker / 定时任务等内部调用 | 保留不带 userId 的内部方法(标记为 `@internal`),仅在 HTTP API 入口层强制校验 |
|
||
| IIT 项目多人协作 | 使用 tenantId 而非 userId 做隔离,同租户成员可共享访问 |
|
||
| 查不到资源时的响应 | 统一返回 404(不返回 403,避免暴露资源是否存在) |
|
||
|
||
## 3.5 IDOR 修复工作量
|
||
|
||
| # | 任务 | 涉及文件 | 预估 |
|
||
|---|------|---------|------|
|
||
| 1 | SSA IDOR 修复 | session.routes.ts, analysis.routes.ts, consult.routes.ts, chat.routes.ts | 2-3h |
|
||
| 2 | DC Tool-C IDOR 修复 | SessionController.ts, SessionService.ts | 1-2h |
|
||
| 3 | ASL IDOR 修复 | ExtractionController.ts, ExtractionService.ts | 1-2h |
|
||
| 4 | IIT IDOR 修复(tenantId) | iitProjectService.ts, iitBatchController.ts, iitQcCockpitController.ts | 1-2h |
|
||
|
||
## 3.6 IDOR 验证方法
|
||
|
||
1. 用户 A 创建 SSA 会话,记录 sessionId
|
||
2. 用户 B 直接请求 `GET /api/v1/ssa/sessions/{sessionId}` → 应返回 404
|
||
3. 用户 B 请求 `POST /api/v1/ssa/sessions/{sessionId}/upload` → 应返回 404
|
||
4. DC Tool-C、ASL 同理测试
|
||
|
||
---
|
||
|
||
# 第四部分:P0 详细修复方案 — 全局 LLM 脱敏
|
||
|
||
## 4.1 问题描述
|
||
|
||
系统通过 `LLMFactory.getAdapter()` 调用外部大模型(DeepSeek、Qwen、GPT-5、Claude),共有 **32+ 调用点**分布在 12 个模块中。但 PII 脱敏仅在 DC 模块的 `DualModelExtractionService` 中实现,**其余所有模块直接将原始临床数据发送给外部 LLM**。
|
||
|
||
## 4.2 当前脱敏覆盖状态
|
||
|
||
| 模块 | 脱敏 | LLM 调用点 |
|
||
|------|------|-----------|
|
||
| DC Tool-B(数据提取) | ✅ 有 maskPII | DualModelExtractionService.ts:149 |
|
||
| DC Tool-C(数据清洗) | ❌ 无 | AICodeService.ts:89,395 / AIController.ts:244 |
|
||
| SSA(智能统计分析) | ❌ 无 | AgentCoderService:57,100 / AgentReviewerService:54 / AgentPlannerService:63 / QueryService:105 等 7 处 |
|
||
| IIT Manager(CRA 质控) | ❌ 无 | ChatOrchestrator:70 / SoftRuleEngine:214 |
|
||
| ASL(文献检索) | ❌ 无 | requirementExpansionService:80 / llmScreeningService:68 / LLM12FieldsService:143 / ExtractionSingleWorker:151 |
|
||
| PKB(知识库) | ❌ 无 | batchService:311 / chatController:231 |
|
||
| RVW(审稿) | ❌ 无 | methodologyService:51 / editorialService:51 |
|
||
| Agent Protocol | ❌ 无 | LLMServiceAdapter:45,91 |
|
||
| 通用层 RAG / Streaming | ❌ 无 | QueryRewriter:33 / StreamingService:50 |
|
||
| Legacy | ❌ 无 | conversationService:371,527 / batchService:311 / reviewService:216,268 / chatController:419 |
|
||
|
||
## 4.3 现有脱敏实现(可复用基础)
|
||
|
||
DC 模块中已有的 `maskPII` 方法(`DualModelExtractionService.ts` 第 98-120 行):
|
||
|
||
```typescript
|
||
private maskPII(text: string): string {
|
||
let masked = text;
|
||
|
||
// 手机号脱敏:138****5678
|
||
masked = masked.replace(/1[3-9]\d{9}/g, (match) => {
|
||
return match.substring(0, 3) + '****' + match.substring(7);
|
||
});
|
||
|
||
// 身份证号脱敏:330102********1234
|
||
masked = masked.replace(
|
||
/\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dxX]/g,
|
||
(match) => match.substring(0, 6) + '********' + match.substring(14)
|
||
);
|
||
|
||
// 患者姓名脱敏
|
||
masked = masked.replace(
|
||
/(患者|姓名[::])\s*([^\s,。,]{2,4})/g,
|
||
(_, prefix, name) => prefix + name[0] + '*'.repeat(name.length - 1)
|
||
);
|
||
|
||
return masked;
|
||
}
|
||
```
|
||
|
||
## 4.4 修复方案
|
||
|
||
### 步骤 1:提取通用 PiiMaskingService
|
||
|
||
新建 `backend/src/common/services/piiMaskingService.ts`:
|
||
|
||
```typescript
|
||
export class PiiMaskingService {
|
||
/**
|
||
* 对文本进行 PII 脱敏
|
||
* 覆盖:手机号、身份证号、患者姓名、邮箱地址
|
||
*/
|
||
static maskText(text: string): string {
|
||
let masked = text;
|
||
|
||
// 手机号:138****5678
|
||
masked = masked.replace(/1[3-9]\d{9}/g,
|
||
m => m.substring(0, 3) + '****' + m.substring(7));
|
||
|
||
// 身份证号:330102********1234
|
||
masked = masked.replace(
|
||
/\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dxX]/g,
|
||
m => m.substring(0, 6) + '********' + m.substring(14));
|
||
|
||
// 患者姓名
|
||
masked = masked.replace(
|
||
/(患者|姓名[::])\s*([^\s,。,]{2,4})/g,
|
||
(_, prefix, name) => prefix + name[0] + '*'.repeat(name.length - 1));
|
||
|
||
// 邮箱地址
|
||
masked = masked.replace(/[\w.-]+@[\w.-]+\.\w+/g, m => {
|
||
const [local, domain] = m.split('@');
|
||
return local[0] + '***@' + domain;
|
||
});
|
||
|
||
return masked;
|
||
}
|
||
|
||
/**
|
||
* 对 LLM 消息数组脱敏(跳过 system 角色的 prompt)
|
||
*/
|
||
static maskMessages(
|
||
messages: Array<{ role: string; content: string }>
|
||
): Array<{ role: string; content: string }> {
|
||
return messages.map(m => ({
|
||
...m,
|
||
content: m.role === 'system' ? m.content : PiiMaskingService.maskText(m.content),
|
||
}));
|
||
}
|
||
}
|
||
```
|
||
|
||
### 步骤 2:在 LLMFactory 层挂载全局脱敏 Proxy
|
||
|
||
修改 `backend/src/common/llm/adapters/LLMFactory.ts`:
|
||
|
||
```typescript
|
||
import { PiiMaskingService } from '../../services/piiMaskingService';
|
||
import { config } from '../../../config/env';
|
||
|
||
/**
|
||
* 脱敏代理 — 包装真实 Adapter,在调用前自动对 messages 脱敏
|
||
*/
|
||
class MaskedLLMAdapter implements ILLMAdapter {
|
||
modelName: string;
|
||
|
||
constructor(private inner: ILLMAdapter) {
|
||
this.modelName = inner.modelName;
|
||
}
|
||
|
||
async chat(messages: Message[], options?: LLMOptions): Promise<LLMResponse> {
|
||
return this.inner.chat(PiiMaskingService.maskMessages(messages), options);
|
||
}
|
||
|
||
async *chatStream(
|
||
messages: Message[],
|
||
options?: LLMOptions,
|
||
onChunk?: (chunk: StreamChunk) => void
|
||
): AsyncGenerator<StreamChunk, void, unknown> {
|
||
yield* this.inner.chatStream(
|
||
PiiMaskingService.maskMessages(messages), options, onChunk
|
||
);
|
||
}
|
||
}
|
||
|
||
// 在 getAdapter() 末尾:
|
||
static getAdapter(modelType: ModelType): ILLMAdapter {
|
||
// ... 原有 switch/case 逻辑不变 ...
|
||
|
||
this.adapters.set(modelType, adapter);
|
||
|
||
// 全局脱敏包装(通过环境变量控制开关)
|
||
if (config.piiMaskingEnabled !== false) {
|
||
return new MaskedLLMAdapter(adapter);
|
||
}
|
||
return adapter;
|
||
}
|
||
```
|
||
|
||
> **注意**:由于 `getAdapter` 使用单例缓存,脱敏 Proxy 应在返回时包装,不应缓存 Proxy 本身(以便运行时切换开关)。
|
||
|
||
### 步骤 3:DC 模块内的 maskPII 改为调用通用服务
|
||
|
||
修改 `backend/src/modules/dc/tool-b/services/DualModelExtractionService.ts`:
|
||
|
||
```typescript
|
||
import { PiiMaskingService } from '../../../../common/services/piiMaskingService';
|
||
|
||
// 删除 private maskPII 方法,改为:
|
||
const maskedText = PiiMaskingService.maskText(input.text);
|
||
```
|
||
|
||
### 步骤 4:添加环境变量开关
|
||
|
||
在 `backend/src/config/env.ts` 中增加:
|
||
|
||
```typescript
|
||
piiMaskingEnabled: process.env.PII_MASKING_ENABLED !== 'false', // 默认开启
|
||
```
|
||
|
||
## 4.5 脱敏影响评估与回退策略
|
||
|
||
| 风险 | 表现 | 缓解措施 |
|
||
|------|------|---------|
|
||
| 误匹配:医学编号被当手机号 | 如药物编号 `13812345678` 被脱敏为 `138****5678` | 在脱敏前排除已知格式(如 CAS 号、临床试验编号) |
|
||
| LLM 回答质量下降 | 脱敏后语义受损 | 仅脱敏 user 消息,不动 system prompt |
|
||
| 回退 | 线上紧急关闭 | 设置 `PII_MASKING_ENABLED=false` 环境变量即可关闭 |
|
||
|
||
**灰度上线建议**:
|
||
1. 第一阶段:仅在 IIT 和 DC 模块启用(临床数据 PII 最密集)
|
||
2. 第二阶段:观察 1-2 天无异常后全量开启
|
||
|
||
## 4.6 脱敏修复工作量
|
||
|
||
| # | 任务 | 涉及文件 | 预估 |
|
||
|---|------|---------|------|
|
||
| 1 | 通用 PiiMaskingService | 新建 common/services/piiMaskingService.ts | 0.5h |
|
||
| 2 | LLMFactory 脱敏 Proxy | LLMFactory.ts | 1h |
|
||
| 3 | DC maskPII 重构 | DualModelExtractionService.ts | 0.5h |
|
||
| 4 | 环境变量 + 配置 | env.ts | 0.5h |
|
||
|
||
## 4.7 脱敏验证方法
|
||
|
||
1. 在任意模块(如 AIA 问答)发送包含手机号 `13800138000` 的消息
|
||
2. 检查后端日志中发送给 LLM 的实际内容,应为 `138****8000`
|
||
3. 设置 `PII_MASKING_ENABLED=false`,重启服务,确认脱敏关闭
|
||
|
||
---
|
||
|
||
# 第五部分:分阶段安全加固路线图
|
||
|
||
## 第一阶段:紧急修复(部署前 1-3 天)
|
||
|
||
- **凭据轮换**:更换所有已暴露在 Git 中的密钥(数据库密码、OSS AK/SK、JWT Secret、LLM API Key 等)
|
||
- **修复 SQL 注入**:`iitQcCockpitController.ts` 和 `VectorSearchService.ts` 中的原始 SQL 改为参数化查询
|
||
- **IDOR 修复**:SSA、DC Tool-C、ASL、IIT 四个模块的水平越权漏洞(详见第三部分)
|
||
- **全局 LLM 脱敏**:通过 LLMFactory Proxy 实现全模块脱敏(详见第四部分)
|
||
- **CORS 白名单**:将 `origin: true` 改为生产域名白名单
|
||
- **关闭/保护 Legacy 路由**:为无认证的 Legacy API 添加认证或下线
|
||
- **关闭 `/test/platform` 路由**:生产环境禁用测试端点
|
||
- **Nginx 安全头**:添加 `X-Frame-Options`、`X-Content-Type-Options`、`X-XSS-Protection`、`Referrer-Policy`
|
||
- **验证码日志脱敏**:生产环境不打印验证码
|
||
- **错误响应脱敏**:生产环境不返回 stack trace
|
||
|
||
## 第二阶段:加固措施(1-2 周)
|
||
|
||
- **HTTPS 配置**:CLB 配置 SSL 证书,开启 443 端口,HTTP 强制跳转 HTTPS,Nginx 添加 HSTS
|
||
- **速率限制**:使用 `@fastify/rate-limit` 对登录/验证码/API 添加限流
|
||
- **登录安全**:实现 5 次失败锁定 15 分钟;记录 IP 和 User-Agent
|
||
- **密码策略强化**:长度 >= 8,包含大小写 + 数字 + 特殊字符
|
||
- **PII 加密**:对 `phone`、`name`、`email` 实施应用层 AES 加密存储
|
||
- **管理员审计日志**:启用 `admin_operation_logs` 表写入
|
||
- **数据库 SSL**:`DATABASE_URL` 添加 `sslmode=require`
|
||
- **extraction_service 非 root**:Dockerfile 添加 `USER appuser`
|
||
|
||
## 第三阶段:阿里云安全产品与制度文档(2-4 周)
|
||
|
||
- 阿里云安全产品采购(详见第六部分)
|
||
- 安全制度文档编写(详见附录 B)
|
||
|
||
---
|
||
|
||
# 第六部分:阿里云安全产品采购建议
|
||
|
||
| 产品 | 用途 | 优先级 | 预估成本 |
|
||
|------|------|--------|---------|
|
||
| **SSL 证书**(免费/付费) | HTTPS 加密传输 | P0 必选 | 免费(DV)或 ~2000/年(OV) |
|
||
| **Web 应用防火墙(WAF)** | SQL 注入/XSS/CC 攻击防护 | P1 强烈建议 | ~3000/年起 |
|
||
| **云安全中心(基础版免费)** | 漏洞扫描、基线检查、入侵检测 | P1 建议 | 免费基础版 |
|
||
| **DDoS 防护(基础版免费)** | 防流量攻击 | P2 建议 | 免费基础版(5Gbps) |
|
||
| **密钥管理服务(KMS)** | 密钥托管,替代环境变量存密钥 | P2 建议 | ~1000/年 |
|
||
| **日志服务(SLS)** | 安全审计日志集中管理 | P2 建议 | 已有,按量 |
|
||
| **RAM 精细化权限** | 最小权限原则 | P2 建议 | 免费 |
|
||
| **操作审计(ActionTrail)** | 云资源操作审计 | P3 加分项 | 免费基础版 |
|
||
|
||
---
|
||
|
||
# 第七部分:安全架构总览(当前 vs 目标)
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
subgraph current [当前架构 - 存在风险]
|
||
User1[用户浏览器] -->|HTTP 明文| CLB1[CLB:80]
|
||
CLB1 --> Nginx1[Nginx - 无安全头]
|
||
Nginx1 -->|HTTP| Backend1["Node.js - CORS:*"]
|
||
Backend1 -->|"无SSL,明文密码在Git"| RDS1[RDS PostgreSQL]
|
||
Backend1 -->|"PII明文"| LLM1[外部LLM]
|
||
end
|
||
|
||
subgraph target [目标架构 - 安全加固后]
|
||
User2[用户浏览器] -->|HTTPS + HSTS| WAF[WAF防火墙]
|
||
WAF --> CLB2[CLB:443 + SSL]
|
||
CLB2 --> Nginx2["Nginx + 安全头 + CSP"]
|
||
Nginx2 -->|HTTP内网| Backend2["Node.js + RateLimit + CORS白名单"]
|
||
Backend2 -->|"SSL + KMS密钥"| RDS2[RDS PostgreSQL + PII加密]
|
||
Backend2 -->|"PII脱敏后"| LLM2[外部LLM]
|
||
Backend2 --> AuditLog[审计日志 → SLS]
|
||
end
|
||
```
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
**当前安全等级评估:中低**
|
||
|
||
最紧迫的问题是**凭据泄露**(文档中明文记录所有生产密钥并提交到 Git)、**SQL 注入**漏洞和 **IDOR 水平越权**(~29 个接口无归属校验)。这些问题如果被利用,可能导致数据库被入侵、全部用户数据泄露。
|
||
|
||
**卫健委审查重点关注项**:
|
||
|
||
1. 数据传输加密(HTTPS)— 当前 CLB 仅 HTTP
|
||
2. 个人信息保护 — PII 明文存储,且未脱敏直接发送给第三方 LLM
|
||
3. 访问控制 — IDOR 水平越权可导致任意用户数据泄露
|
||
4. 临床数据发送给第三方 LLM 的合规性
|
||
5. 审计日志完整性
|
||
6. 数据备份与恢复能力
|
||
|
||
建议按 P0 → P1 → P2 优先级逐步修复,第一阶段的紧急修复可在 2-3 天内完成。
|
||
|
||
---
|
||
|
||
# 附录 A:LLM 调用点完整清单
|
||
|
||
以下为 `LLMFactory.getAdapter()` 的所有调用点,全局脱敏 Proxy 将自动覆盖:
|
||
|
||
| 模块 | 文件 | 行号 |
|
||
|------|------|------|
|
||
| DC Tool-B | dc/tool-b/services/DualModelExtractionService.ts | 149 |
|
||
| DC Tool-C | dc/tool-c/services/AICodeService.ts | 89, 395 |
|
||
| DC Tool-C | dc/tool-c/controllers/AIController.ts | 244 |
|
||
| SSA | ssa/services/AgentCoderService.ts | 57, 100 |
|
||
| SSA | ssa/services/AgentReviewerService.ts | 54 |
|
||
| SSA | ssa/services/AgentPlannerService.ts | 63 |
|
||
| SSA | ssa/services/ReflectionService.ts | 95 |
|
||
| SSA | ssa/services/QueryService.ts | 105 |
|
||
| SSA | ssa/services/PicoInferenceService.ts | 52 |
|
||
| SSA | ssa/services/IntentRouterService.ts | 181 |
|
||
| SSA | ssa/services/ConversationService.ts | 253 |
|
||
| IIT Manager | iit-manager/services/ChatOrchestrator.ts | 70 |
|
||
| IIT Manager | iit-manager/engines/SoftRuleEngine.ts | 214 |
|
||
| IIT Admin | admin/iit-projects/iitRuleSuggestionService.ts | 184 |
|
||
| ASL | asl/services/requirementExpansionService.ts | 80 |
|
||
| ASL | asl/services/llmScreeningService.ts | 68 |
|
||
| ASL | asl/common/llm/LLM12FieldsService.ts | 143 |
|
||
| ASL | asl/extraction/workers/ExtractionSingleWorker.ts | 151 |
|
||
| PKB | pkb/services/batchService.ts | 311 |
|
||
| PKB | pkb/controllers/chatController.ts | 231 |
|
||
| RVW | rvw/services/methodologyService.ts | 51 |
|
||
| RVW | rvw/services/editorialService.ts | 51 |
|
||
| Agent Protocol | agent/protocol/services/LLMServiceAdapter.ts | 45, 91 |
|
||
| 通用 RAG | common/rag/QueryRewriter.ts | 33 |
|
||
| 通用 Streaming | common/streaming/StreamingService.ts | 50 |
|
||
| Legacy | legacy/services/conversationService.ts | 371, 527 |
|
||
| Legacy | legacy/services/batchService.ts | 311 |
|
||
| Legacy | legacy/services/reviewService.ts | 216, 268 |
|
||
| Legacy | legacy/controllers/chatController.ts | 419 |
|
||
|
||
---
|
||
|
||
# 附录 B:安全制度文档编写清单
|
||
|
||
以下文档建议在安全审查前准备,用于汇报材料:
|
||
|
||
1. **《信息安全管理制度》** — 覆盖组织架构、职责分工、安全流程
|
||
2. **《数据分类分级管理制度》** — 临床研究数据属敏感数据,需分级管理
|
||
3. **《个人信息保护影响评估报告(PIA)》** — 手机号、姓名等 PII 的收集、使用、存储评估
|
||
4. **《数据安全应急预案》** — 数据泄露事件的响应流程
|
||
5. **《LLM 数据安全合规说明》** — 说明数据发送给哪些 LLM、是否脱敏、数据保留政策
|
||
6. **《等保三级自评清单》** — 卫健委可能要求
|