docs(security): 安全评估报告 + IDOR修复方案 + 安全开发规范v1.1
- 新增安全评估报告与P0修复方案(IDOR水平越权~29接口 + 全局LLM脱敏) - 新增安全开发规范v1.1(9大安全规范 + Code Review检查清单) - 采纳架构师审查意见补充OSS签名URL、导出审计、接口限流、依赖安全 - 更新开发规范README索引 Made-with: Cursor
This commit is contained in:
723
docs/04-开发规范/12-安全开发规范.md
Normal file
723
docs/04-开发规范/12-安全开发规范.md
Normal file
@@ -0,0 +1,723 @@
|
||||
# 安全开发规范
|
||||
|
||||
> **版本:** v1.1
|
||||
> **创建日期:** 2026-03-02
|
||||
> **最后更新:** 2026-03-03(v1.1:采纳架构师审查意见,补充 OSS 签名 URL、导出审计、接口限流、依赖安全)
|
||||
> **强制等级:** P0 必须遵守
|
||||
> **适用范围:** 前端(React/TypeScript)+ 后端(Node.js/TypeScript)+ 部署(Docker/SAE)
|
||||
> **背景:** 本规范基于 2026 年 3 月安全评估中发现的实际漏洞总结而成,所有条目均对应真实代码问题
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [API 访问控制规范(防 IDOR)](#1-api-访问控制规范防-idor)
|
||||
2. [数据库查询安全规范(防 SQL 注入)](#2-数据库查询安全规范防-sql-注入)
|
||||
3. [LLM 调用安全规范(PII 脱敏)](#3-llm-调用安全规范pii-脱敏)
|
||||
4. [认证与授权规范](#4-认证与授权规范)
|
||||
5. [敏感信息管理规范(防泄露)](#5-敏感信息管理规范防泄露)
|
||||
6. [前端安全规范](#6-前端安全规范)
|
||||
7. [日志与错误处理安全规范](#7-日志与错误处理安全规范)
|
||||
8. [部署与网络安全规范](#8-部署与网络安全规范)
|
||||
9. [Code Review 安全检查清单](#9-code-review-安全检查清单)
|
||||
|
||||
---
|
||||
|
||||
## 1. API 访问控制规范(防 IDOR)
|
||||
|
||||
> IDOR(Insecure Direct Object Reference,不安全的直接对象引用)是本系统曾出现的最大面积漏洞,影响 ~29 个接口。以下规范**强制执行**。
|
||||
|
||||
### 1.1 核心原则
|
||||
|
||||
**任何通过 ID 访问资源的 API,必须在查询条件中包含归属校验。**
|
||||
|
||||
- 个人资源(会话、文档、任务):必须加 `userId`
|
||||
- 租户资源(项目、团队数据):必须加 `tenantId`
|
||||
- 禁止仅凭资源 ID 查询/修改/删除
|
||||
|
||||
### 1.2 正确写法
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:查询条件包含 userId
|
||||
const session = await prisma.ssaSession.findFirst({
|
||||
where: { id: sessionId, userId },
|
||||
});
|
||||
if (!session) {
|
||||
return reply.status(404).send({ error: '资源不存在' });
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:通过关联关系校验归属
|
||||
const result = await prisma.aslExtractionResult.findFirst({
|
||||
where: {
|
||||
id: resultId,
|
||||
task: { userId },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:租户资源按 tenantId 过滤
|
||||
const project = await prisma.iitProject.findFirst({
|
||||
where: { id: projectId, tenantId, deletedAt: null },
|
||||
});
|
||||
```
|
||||
|
||||
### 1.3 错误写法(禁止)
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止:仅凭 id 查询,任何登录用户都可访问
|
||||
const session = await prisma.ssaSession.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
// ❌ 禁止:update 时不校验归属
|
||||
await prisma.ssaSession.update({
|
||||
where: { id },
|
||||
data: { ... },
|
||||
});
|
||||
|
||||
// ❌ 禁止:通过子资源 ID 查询不校验父资源归属
|
||||
const messages = await prisma.ssaMessage.findMany({
|
||||
where: { sessionId: id },
|
||||
});
|
||||
```
|
||||
|
||||
### 1.4 Controller → Service 传参规范
|
||||
|
||||
Controller 层**必须**从 request 获取 userId/tenantId 并传入 Service:
|
||||
|
||||
```typescript
|
||||
// Controller 层
|
||||
const userId = getUserId(request);
|
||||
const result = await myService.getResource(resourceId, userId);
|
||||
|
||||
// Service 层
|
||||
async getResource(resourceId: string, userId: string) {
|
||||
const resource = await prisma.myTable.findFirst({
|
||||
where: { id: resourceId, userId },
|
||||
});
|
||||
if (!resource) throw new NotFoundError('资源不存在');
|
||||
return resource;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 特殊场景处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| SUPER_ADMIN 查看任意资源 | Service 方法判断角色,SUPER_ADMIN 可跳过 userId 校验 |
|
||||
| Worker / 后台任务 | 保留无 userId 参数的内部方法,方法名加 `Internal` 后缀(如 `getSessionInternal`) |
|
||||
| 多人协作资源(IIT 项目) | 使用 `tenantId` 而非 `userId`,同租户成员共享访问 |
|
||||
| 资源不存在 vs 无权限 | 统一返回 **404**(不返回 403,避免暴露资源存在性) |
|
||||
|
||||
### 1.6 已有的正确实现(参考模板)
|
||||
|
||||
新模块开发时,参考以下已正确实现归属校验的模块:
|
||||
|
||||
- **PKB**:`documentService.getDocumentById(userId, id)` — `where: { id, userId }`
|
||||
- **RVW**:`reviewService.getTaskDetail(userId, taskId)` — `where: { id: taskId, userId }`
|
||||
- **AIA**:`conversationService.getConversationById(userId, id)` — `where: { id, userId }`
|
||||
|
||||
### 1.7 OSS 签名 URL 安全要求
|
||||
|
||||
文件下载是 IDOR 的重灾区。所有通过 OSS 签名 URL 提供文件下载的接口,必须遵守:
|
||||
|
||||
**归属校验(与 1.1 一致)**:生成签名 URL 前,必须先校验文件/会话/任务归属当前用户。
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:先校验归属,再生成签名 URL
|
||||
const document = await prisma.document.findFirst({
|
||||
where: { id: documentId, userId },
|
||||
});
|
||||
if (!document) return reply.status(404).send({ error: '文件不存在' });
|
||||
|
||||
const signedUrl = await storage.getSignedUrl(document.ossKey, 300);
|
||||
return reply.send({ url: signedUrl });
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止:不校验归属直接生成签名 URL
|
||||
const document = await prisma.document.findUnique({ where: { id: documentId } });
|
||||
const signedUrl = await storage.getSignedUrl(document.ossKey);
|
||||
```
|
||||
|
||||
**签名 URL 有效期**:
|
||||
|
||||
| 场景 | 最大有效期 | 说明 |
|
||||
|------|-----------|------|
|
||||
| 文档预览/下载 | 300 秒(5 分钟) | 用户点击后立即下载,无需长时效 |
|
||||
| 大文件下载(>100MB) | 900 秒(15 分钟) | 为慢速网络留余量 |
|
||||
| 内部服务间传递 | 3600 秒(1 小时) | Worker 处理数据时使用,非用户直接访问 |
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:显式设置短有效期
|
||||
const signedUrl = await storage.getSignedUrl(ossKey, 300);
|
||||
|
||||
// ❌ 不推荐:使用默认有效期(当前默认 3600 秒,对用户下载偏长)
|
||||
const signedUrl = await storage.getSignedUrl(ossKey);
|
||||
```
|
||||
|
||||
**Bucket 权限**:代码中严禁调用 `putBucketAcl` 或将 Bucket 设置为 `public-read`(静态资源 Bucket 除外)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库查询安全规范(防 SQL 注入)
|
||||
|
||||
### 2.1 核心原则
|
||||
|
||||
**禁止将用户输入直接拼接到 SQL 字符串中。必须使用参数化查询。**
|
||||
|
||||
### 2.2 Prisma ORM 查询(推荐)
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用 Prisma 标准 API(自动参数化)
|
||||
const records = await prisma.qcRecord.findMany({
|
||||
where: { projectId, status },
|
||||
skip: offset,
|
||||
take: pageSize,
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 原始 SQL 查询
|
||||
|
||||
当必须使用原始 SQL 时(如复杂聚合、向量搜索),使用 Prisma 的参数化原始查询:
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:$queryRaw 使用模板字面量(自动参数化)
|
||||
const results = await prisma.$queryRaw`
|
||||
SELECT * FROM iit_qc_records
|
||||
WHERE project_id = ${projectId}
|
||||
AND status = ${status}
|
||||
LIMIT ${pageSize} OFFSET ${offset}
|
||||
`;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:Prisma.sql 拼接动态条件
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
const conditions: Prisma.Sql[] = [Prisma.sql`project_id = ${projectId}`];
|
||||
if (status) {
|
||||
conditions.push(Prisma.sql`status = ${status}`);
|
||||
}
|
||||
const whereClause = Prisma.join(conditions, ' AND ');
|
||||
const results = await prisma.$queryRaw`
|
||||
SELECT * FROM iit_qc_records WHERE ${whereClause}
|
||||
`;
|
||||
```
|
||||
|
||||
### 2.4 禁止写法
|
||||
|
||||
```typescript
|
||||
// ❌ 致命:$queryRawUnsafe + 字符串拼接
|
||||
const results = await prisma.$queryRawUnsafe(`
|
||||
SELECT * FROM records
|
||||
WHERE project_id = '${projectId}'
|
||||
AND status = '${status}'
|
||||
LIMIT ${pageSize} OFFSET ${offset}
|
||||
`);
|
||||
|
||||
// ❌ 危险:单引号转义不能防止所有注入
|
||||
const safeKbId = kbId.replace(/'/g, "''");
|
||||
const sql = `SELECT * FROM documents WHERE kb_id = '${safeKbId}'`;
|
||||
```
|
||||
|
||||
### 2.5 向量搜索场景
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:pgvector 查询使用参数化
|
||||
const results = await prisma.$queryRaw`
|
||||
SELECT id, content, 1 - (embedding <=> ${vectorParam}::vector) AS similarity
|
||||
FROM document_chunks
|
||||
WHERE kb_id = ${kbId}
|
||||
ORDER BY embedding <=> ${vectorParam}::vector
|
||||
LIMIT ${topK}
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM 调用安全规范(PII 脱敏)
|
||||
|
||||
### 3.1 核心原则
|
||||
|
||||
**所有发送给外部 LLM 的用户数据必须经过 PII 脱敏处理。**
|
||||
|
||||
### 3.2 架构方案
|
||||
|
||||
系统通过 `LLMFactory.getAdapter()` 统一创建 LLM 适配器。全局脱敏通过 `MaskedLLMAdapter` 代理实现,**默认开启**,无需各模块单独处理。
|
||||
|
||||
```
|
||||
业务模块 → LLMFactory.getAdapter() → MaskedLLMAdapter(自动脱敏)→ 真实 Adapter → 外部 LLM
|
||||
```
|
||||
|
||||
### 3.3 脱敏覆盖范围
|
||||
|
||||
| PII 类型 | 脱敏规则 | 示例 |
|
||||
|----------|---------|------|
|
||||
| 手机号 | 保留前 3 后 4 | `13800138000` → `138****8000` |
|
||||
| 身份证号 | 保留前 6 后 4 | `330102199001011234` → `330102********1234` |
|
||||
| 患者姓名 | 保留姓氏 | `患者张三丰` → `患者张**` |
|
||||
| 邮箱地址 | 保留首字符和域名 | `zhangsan@example.com` → `z***@example.com` |
|
||||
|
||||
### 3.4 开发时注意事项
|
||||
|
||||
- **不要在业务代码中重复实现脱敏**:全局脱敏由 `MaskedLLMAdapter` 自动完成
|
||||
- **System Prompt 不会被脱敏**:只有 `user` 和 `assistant` 角色的消息会被脱敏
|
||||
- **如需关闭脱敏**(如调试):设置环境变量 `PII_MASKING_ENABLED=false`
|
||||
- **如需扩展脱敏规则**:修改 `backend/src/common/services/piiMaskingService.ts`
|
||||
|
||||
### 3.5 使用通用脱敏服务
|
||||
|
||||
当业务代码需要在非 LLM 场景下脱敏(如日志、API 响应):
|
||||
|
||||
```typescript
|
||||
import { PiiMaskingService } from '@/common/services/piiMaskingService';
|
||||
|
||||
// 脱敏单段文本
|
||||
const masked = PiiMaskingService.maskText(rawText);
|
||||
|
||||
// 脱敏 LLM 消息数组
|
||||
const maskedMessages = PiiMaskingService.maskMessages(messages);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 认证与授权规范
|
||||
|
||||
### 4.1 路由注册规范
|
||||
|
||||
**所有 API 路由必须挂载 `authenticate` 中间件,除非有明确的豁免理由。**
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:路由级别挂载认证
|
||||
app.register(async (protectedApp) => {
|
||||
protectedApp.addHook('preHandler', authenticate);
|
||||
protectedApp.register(myRoutes, { prefix: '/api/v2/my-module' });
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止:未挂载认证的业务路由
|
||||
app.register(myRoutes, { prefix: '/api/v1/my-module' });
|
||||
```
|
||||
|
||||
### 4.2 角色与模块权限
|
||||
|
||||
```typescript
|
||||
// 需要特定角色
|
||||
app.get('/admin/users', {
|
||||
preHandler: [authenticate, requireRoles(['SUPER_ADMIN', 'ORG_ADMIN'])],
|
||||
}, handler);
|
||||
|
||||
// 需要模块订阅
|
||||
app.get('/ssa/sessions', {
|
||||
preHandler: [authenticate, requireModule('SSA')],
|
||||
}, handler);
|
||||
```
|
||||
|
||||
### 4.3 测试/调试端点
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:生产环境禁用测试端点
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.register(testRoutes, { prefix: '/test' });
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止:测试端点无条件注册且无认证
|
||||
app.register(testRoutes, { prefix: '/test' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 敏感信息管理规范(防泄露)
|
||||
|
||||
### 5.1 环境变量与密钥
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 密钥只通过环境变量注入 | 数据库密码、API Key、JWT Secret 等**严禁硬编码** |
|
||||
| `.env` 文件不提交 Git | 确认在 `.gitignore` 中 |
|
||||
| `.env.example` 使用占位符 | 示例值写 `your-api-key-here`,**不写真实密钥** |
|
||||
| 文档中不记录密钥 | 部署文档写"见阿里云控制台",不写实际密码 |
|
||||
| 脚本/测试代码不硬编码密钥 | 从 `process.env` 或配置文件读取 |
|
||||
|
||||
### 5.2 正确写法
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:从环境变量读取
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is required');
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止:硬编码密钥
|
||||
const apiKey = 'sk-7f8cc37a79fa4799860b38fc7ba2e150';
|
||||
```
|
||||
|
||||
### 5.3 `.env.example` 规范
|
||||
|
||||
```env
|
||||
# ✅ 正确
|
||||
DATABASE_URL=postgresql://postgres:your-password@localhost:5432/ai_clinical_research
|
||||
JWT_SECRET=replace-with-a-strong-64-char-secret
|
||||
DEEPSEEK_API_KEY=your-api-key-here
|
||||
|
||||
# ❌ 错误
|
||||
DATABASE_URL=postgresql://postgres:Xibahe@fengzhibo117@rds-xxx.rds.aliyuncs.com:5432/ai_clinical_research
|
||||
DEEPSEEK_API_KEY=sk-7f8cc37a79fa4799860b38fc7ba2e150
|
||||
```
|
||||
|
||||
### 5.4 密钥泄露应急流程
|
||||
|
||||
如果发现密钥已泄露(提交到 Git、发到聊天群等):
|
||||
|
||||
1. **立即轮换**:更换泄露的密钥/密码
|
||||
2. **清理 Git 历史**:使用 BFG Repo-Cleaner 或 `git filter-branch`
|
||||
3. **排查影响**:检查日志确认是否有异常访问
|
||||
4. **通知团队**:更新相关服务的配置
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端安全规范
|
||||
|
||||
### 6.1 Token 存储
|
||||
|
||||
当前使用 `localStorage` 存储 JWT Token。后续优化方向:
|
||||
|
||||
- 考虑迁移到 `httpOnly` Cookie(防 XSS 读取)
|
||||
- Access Token 设置较短过期时间(当前 2h,合理)
|
||||
- Refresh Token 仅在续期时使用
|
||||
|
||||
### 6.2 XSS 防护
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:React JSX 自动转义
|
||||
<div>{userInput}</div>
|
||||
|
||||
// ❌ 禁止:dangerouslySetInnerHTML 使用未经净化的内容
|
||||
<div dangerouslySetInnerHTML={{ __html: userInput }} />
|
||||
|
||||
// ✅ 如必须渲染 HTML,先使用 DOMPurify 净化
|
||||
import DOMPurify from 'dompurify';
|
||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlContent) }} />
|
||||
```
|
||||
|
||||
### 6.3 API 请求
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:使用统一的 apiClient(自动带 Token、处理 401)
|
||||
import apiClient from '@/common/api/axios';
|
||||
const { data } = await apiClient.get('/api/v2/xxx');
|
||||
|
||||
// ❌ 禁止:直接用 fetch/axios 不带认证
|
||||
const res = await fetch('/api/v2/xxx');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 日志与错误处理安全规范
|
||||
|
||||
### 7.1 日志脱敏
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:日志中脱敏敏感信息
|
||||
logger.info('验证码已发送', { phone: phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') });
|
||||
|
||||
// ❌ 禁止:明文记录验证码、密码、Token
|
||||
logger.info('验证码已生成', { phone, code });
|
||||
console.log(`验证码: ${code}`);
|
||||
```
|
||||
|
||||
### 7.2 错误响应脱敏
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:生产环境返回通用错误信息
|
||||
return reply.status(500).send({
|
||||
error: '服务内部错误',
|
||||
...(process.env.NODE_ENV !== 'production' && { stack: error.stack }),
|
||||
});
|
||||
|
||||
// ❌ 禁止:生产环境返回完整错误栈
|
||||
return reply.status(500).send({
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
```
|
||||
|
||||
### 7.3 管理与高危操作审计日志
|
||||
|
||||
以下两类操作**必须**记录审计日志:
|
||||
|
||||
**管理员增删改操作**:
|
||||
|
||||
```typescript
|
||||
await prisma.adminOperationLog.create({
|
||||
data: {
|
||||
userId: currentUser.id,
|
||||
action: 'DELETE_USER',
|
||||
targetId: targetUserId,
|
||||
details: JSON.stringify({ reason }),
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**数据导出/批量下载操作**:
|
||||
|
||||
凡涉及"报表导出"、"Excel 数据下载"、"批量文献导出"、"文档下载"的接口,必须调用 `activityService.log()` 记录导出日志。这是发生数据泄露时的唯一溯源手段。
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:导出接口记录审计日志
|
||||
app.get('/sessions/:id/export', async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
const session = await sessionService.getSession(id, userId);
|
||||
|
||||
// 执行导出...
|
||||
const exportData = await sessionService.exportData(session);
|
||||
|
||||
// 记录导出日志
|
||||
await activityService.log({
|
||||
userId,
|
||||
action: 'EXPORT',
|
||||
module: 'DC_TOOL_C',
|
||||
targetId: session.id,
|
||||
details: JSON.stringify({
|
||||
exportType: 'excel',
|
||||
rowCount: exportData.rowCount,
|
||||
}),
|
||||
ip: request.ip,
|
||||
});
|
||||
|
||||
return reply.send(exportData.buffer);
|
||||
});
|
||||
```
|
||||
|
||||
**必须记录导出日志的接口清单**(新增接口时参照补充):
|
||||
|
||||
| 接口 | 模块 |
|
||||
|------|------|
|
||||
| `/dc/tool-b/tasks/:taskId/export` | DC 数据提取 |
|
||||
| `/dc/tool-c/sessions/:id/export` | DC 数据清洗 |
|
||||
| `/asl/extraction/tasks/:taskId/export` | ASL 提取结果导出 |
|
||||
| `/asl/fulltext-screening/tasks/:taskId/export` | ASL 全文筛选导出 |
|
||||
| `/asl/research/tasks/:taskId/export-word` | ASL 深度研究导出 |
|
||||
| `/aia/protocol-agent/export/docx` | 研究方案导出 |
|
||||
| `/ssa/sessions/:id/download-code` | SSA 代码下载 |
|
||||
| `/ssa/sessions/:id/download-sap` | SSA SAP 下载 |
|
||||
| `/admin/system-kb/:id/documents/:docId/download` | 系统知识库下载 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 部署与网络安全规范
|
||||
|
||||
### 8.1 Docker 安全
|
||||
|
||||
```dockerfile
|
||||
# ✅ 正确:使用非 root 用户运行
|
||||
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroup
|
||||
USER appuser
|
||||
|
||||
# ✅ 正确:多阶段构建,减少攻击面
|
||||
FROM node:20-slim AS builder
|
||||
# ... 构建 ...
|
||||
FROM node:20-slim AS runner
|
||||
COPY --from=builder /app/dist ./dist
|
||||
```
|
||||
|
||||
### 8.2 Nginx 安全头
|
||||
|
||||
```nginx
|
||||
# 必须配置的安全响应头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# HTTPS 部署后启用
|
||||
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
# add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'" always;
|
||||
```
|
||||
|
||||
### 8.3 CORS 配置
|
||||
|
||||
```typescript
|
||||
// ✅ 正确:生产环境使用白名单
|
||||
app.register(cors, {
|
||||
origin: process.env.NODE_ENV === 'production'
|
||||
? ['https://iit.xunzhengyixue.com']
|
||||
: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// ❌ 禁止:生产环境允许所有来源
|
||||
app.register(cors, { origin: true });
|
||||
```
|
||||
|
||||
### 8.4 数据库连接
|
||||
|
||||
```env
|
||||
# ✅ 正确:启用 SSL
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require
|
||||
|
||||
# ❌ 不推荐:无 SSL
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
```
|
||||
|
||||
### 8.5 接口限流与防刷规范
|
||||
|
||||
所有暴露到公网的接口必须配置速率限制,防止暴力破解和数据爬取。使用 `@fastify/rate-limit` 中间件。
|
||||
|
||||
**全局默认限流**(在 `backend/src/index.ts` 中注册):
|
||||
|
||||
```typescript
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
|
||||
await app.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
});
|
||||
```
|
||||
|
||||
**敏感接口独立限流**(更严格):
|
||||
|
||||
```typescript
|
||||
// 登录接口:单 IP 每分钟最多 5 次
|
||||
app.post('/api/v1/auth/login', {
|
||||
config: {
|
||||
rateLimit: { max: 5, timeWindow: '1 minute' },
|
||||
},
|
||||
}, loginHandler);
|
||||
|
||||
// 验证码接口:单手机号每分钟 1 次(业务层已有),单 IP 每分钟 5 次
|
||||
app.post('/api/v1/auth/send-code', {
|
||||
config: {
|
||||
rateLimit: { max: 5, timeWindow: '1 minute' },
|
||||
},
|
||||
}, sendCodeHandler);
|
||||
```
|
||||
|
||||
**登录失败锁定**(在 `auth.service.ts` 中实现):
|
||||
|
||||
- 同一账号连续登录失败 5 次 → 锁定 15 分钟
|
||||
- 锁定期间返回 `429 Too Many Requests`
|
||||
- 锁定计数存入 cache(如 `PostgresCacheAdapter`),成功登录后重置
|
||||
|
||||
### 8.6 依赖安全审查
|
||||
|
||||
每次发版前必须检查 npm 依赖的已知漏洞。
|
||||
|
||||
**本地检查**:
|
||||
|
||||
```bash
|
||||
# 检查后端依赖漏洞
|
||||
cd backend && npm audit
|
||||
|
||||
# 检查前端依赖漏洞
|
||||
cd frontend-v2 && npm audit
|
||||
```
|
||||
|
||||
**CI/CD 集成**(搭建流水线时必须配置):
|
||||
|
||||
```yaml
|
||||
# GitHub Actions 示例
|
||||
- name: Security audit
|
||||
run: npm audit --audit-level=high
|
||||
# high/critical 级别漏洞必须修复后才能上线
|
||||
```
|
||||
|
||||
**package.json 中添加审查脚本**:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"audit": "npm audit --audit-level=high",
|
||||
"audit:fix": "npm audit fix"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Code Review 安全检查清单
|
||||
|
||||
每次 Code Review 时,以下安全项**必须检查**:
|
||||
|
||||
### 9.1 访问控制
|
||||
|
||||
- [ ] 新增/修改的 API 是否挂载了 `authenticate` 中间件?
|
||||
- [ ] 通过 ID 查询的接口是否在 `where` 条件中包含 `userId` 或 `tenantId`?
|
||||
- [ ] `findUnique`/`findFirst` 查询是否只按 `id`?(如果是,很可能有 IDOR 风险)
|
||||
- [ ] `update`/`delete` 操作是否校验了资源归属?
|
||||
|
||||
### 9.2 输入安全
|
||||
|
||||
- [ ] 是否使用了 `$queryRawUnsafe`?(需特别审查)
|
||||
- [ ] 原始 SQL 中是否有字符串拼接用户输入?
|
||||
- [ ] 用户上传文件是否做了类型和大小校验?
|
||||
|
||||
### 9.3 敏感信息
|
||||
|
||||
- [ ] 代码中是否有硬编码的密钥、密码、API Key?
|
||||
- [ ] 日志中是否打印了敏感信息(验证码、Token、密码)?
|
||||
- [ ] 错误响应是否泄露了内部实现(stack trace、SQL 语句)?
|
||||
|
||||
### 9.4 LLM 安全
|
||||
|
||||
- [ ] 新增的 LLM 调用是否通过 `LLMFactory.getAdapter()` 统一入口?
|
||||
- [ ] 是否有绕过全局脱敏直接调用外部 API 的代码?
|
||||
|
||||
### 9.5 前端安全
|
||||
|
||||
- [ ] 是否使用了 `dangerouslySetInnerHTML`?如是,内容是否经过 DOMPurify 净化?
|
||||
- [ ] API 调用是否使用统一的 `apiClient`?
|
||||
- [ ] 是否有敏感信息写入 `localStorage`/`console.log`?
|
||||
|
||||
### 9.6 文件下载与数据导出
|
||||
|
||||
- [ ] 生成的文件下载链接是否为短时效(<= 300 秒)的预签名 URL?
|
||||
- [ ] 导出/下载接口是否记录了审计日志(`activityService.log` 或 `adminOperationLog`)?
|
||||
|
||||
### 9.7 限流与防刷
|
||||
|
||||
- [ ] 暴露到公网的查询、登录接口是否配置了 Rate Limit?
|
||||
|
||||
---
|
||||
|
||||
## 常见问题 FAQ
|
||||
|
||||
### Q: 为什么查不到资源要返回 404 而不是 403?
|
||||
|
||||
返回 403 会暴露"资源存在但你没权限"这一信息,攻击者可以通过此响应确认某个 ID 对应的资源确实存在。统一返回 404,攻击者无法区分"资源不存在"和"无权访问"。
|
||||
|
||||
### Q: SUPER_ADMIN 需要跨用户查看数据怎么办?
|
||||
|
||||
在 Service 方法中通过角色判断跳过归属校验:
|
||||
|
||||
```typescript
|
||||
async getSession(sessionId: string, userId: string, role: string) {
|
||||
const where: any = { id: sessionId };
|
||||
if (role !== 'SUPER_ADMIN') {
|
||||
where.userId = userId;
|
||||
}
|
||||
return prisma.ssaSession.findFirst({ where });
|
||||
}
|
||||
```
|
||||
|
||||
### Q: 后台 Worker 没有用户上下文怎么校验归属?
|
||||
|
||||
Worker 内部调用使用 `xxxInternal` 方法,该方法不校验 userId。但必须确保 Worker 处理的数据 ID 本身来自已校验过归属的上游流程(如任务队列中的 taskId 是由已认证的用户创建的)。
|
||||
|
||||
### Q: 哪些数据应该做 PII 脱敏?
|
||||
|
||||
所有可直接或间接识别到自然人的信息:手机号、身份证号、姓名、邮箱、住院号、病历号、地址等。当前系统自动脱敏前四类,如业务中出现其他 PII 类型,需在 `PiiMaskingService` 中扩展规则。
|
||||
|
||||
---
|
||||
|
||||
**最后更新:** 2026-03-03(v1.1)
|
||||
**维护人:** 技术架构师
|
||||
@@ -136,12 +136,34 @@ test: 测试 chore: 构建 ci: CI/CD
|
||||
|
||||
---
|
||||
|
||||
### 12. 安全开发规范 ⭐⭐⭐⭐⭐ ✅ 新增
|
||||
**文件:** `12-安全开发规范.md`
|
||||
|
||||
**核心内容:**
|
||||
- API 访问控制(防 IDOR 水平越权)
|
||||
- 数据库查询安全(防 SQL 注入)
|
||||
- LLM 调用 PII 脱敏规范
|
||||
- 认证与授权规范
|
||||
- 敏感信息管理(防泄露)
|
||||
- 前端安全规范
|
||||
- 日志与错误处理安全
|
||||
- 部署与网络安全
|
||||
- Code Review 安全检查清单
|
||||
|
||||
**使用场景:**
|
||||
- 新增 API 接口时,检查访问控制是否正确
|
||||
- Code Review 时,对照安全检查清单逐项检查
|
||||
- 处理用户数据时,确认脱敏和加密要求
|
||||
|
||||
---
|
||||
|
||||
## 🎯 规范优先级
|
||||
|
||||
### P0 - 必须遵守
|
||||
- ✅ 数据库设计规范
|
||||
- ✅ API设计规范
|
||||
- ✅ Git提交规范(Commit Message)
|
||||
- ✅ **安全开发规范** ⭐ 新增
|
||||
|
||||
### P1 - 强烈建议
|
||||
- ✅ 代码规范(TypeScript/React)
|
||||
@@ -165,7 +187,9 @@ test: 测试 chore: 构建 ci: CI/CD
|
||||
**我要提交代码:** → `06-Git提交规范.md` ✅
|
||||
**我要解决中文乱码:** → `06-Git提交规范.md` (第4节) ✅
|
||||
**我要配置远程仓库:** → `06-Git提交规范.md` (第1节) ✅
|
||||
**我要编写测试:** → `07-测试规范.md`
|
||||
**我要编写测试:** → `07-测试规范.md`
|
||||
**我要确保代码安全:** → `12-安全开发规范.md` ✅
|
||||
**我要做 Code Review:** → `12-安全开发规范.md` (第9节 检查清单) ✅
|
||||
|
||||
---
|
||||
|
||||
|
||||
885
docs/08-项目管理/05-技术债务/P0-IDOR水平越权修复与全局LLM脱敏方案.md
Normal file
885
docs/08-项目管理/05-技术债务/P0-IDOR水平越权修复与全局LLM脱敏方案.md
Normal file
@@ -0,0 +1,885 @@
|
||||
# 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. **《等保三级自评清单》** — 卫健委可能要求
|
||||
81
docs/09-架构实施/安全开发规范(v1.0)架构师审查与补充评估报告.md
Normal file
81
docs/09-架构实施/安全开发规范(v1.0)架构师审查与补充评估报告.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# **《“医点点”平台安全开发规范(v1.0)》架构师审查与补充评估报告**
|
||||
|
||||
**审查人:** 资深技术架构师
|
||||
|
||||
**审查日期:** 2026-03-03
|
||||
|
||||
**审查对象:** 12-安全开发规范.md (v1.0)
|
||||
|
||||
**总体评价:** 基础扎实,针对性强,有效覆盖了 OWASP Top 10 中的注入和越权风险。但在文件存储安全、业务风控防刷、底层数据加密方面存在明显遗漏。
|
||||
|
||||
## **一、 核心遗漏点(P0/P1级别盲区)**
|
||||
|
||||
在之前的《关于“医点点”平台安全审查报告的补充评估与攻坚方案》(Security\_Action\_Plan.md)中,我们明确了卫健委窃取数据的几大通道,但在 v1.0 规范中并未体现对这些通道的防御指导:
|
||||
|
||||
### **遗漏 1:OSS 对象存储与文件下载规范(P0级遗漏)**
|
||||
|
||||
**现状问题:** 规范中提到了 API 的 IDOR 防护,但**完全没有提及文件/附件的下载安全规范**。卫健委正是通过遍历文件下载链接拿走的数据。
|
||||
|
||||
**补充建议:** 必须在规范中新增专门的【OSS 与文件存储安全规范】章节,强制要求:
|
||||
|
||||
1. **禁止前端直连**:前端永远不允许拼接 OSS 的固定 URL 下载文件。
|
||||
2. **鉴权后签名**:前端必须调用后端的 /api/v2/files/:fileId/download-url 接口,后端在校验 userId 所属权后,临时生成阿里云 OSS 预签名 URL(Pre-signed URL)。
|
||||
3. **极短有效期**:预签名 URL 的有效期强制设定为 **\<= 60 秒**,严禁生成长效链接。
|
||||
4. **Bucket 权限**:代码中严禁调用将 Bucket 设置为 public-read 的 SDK API。
|
||||
|
||||
### **遗漏 2:数据导出/批量下载的审计日志盲区(P1级遗漏)**
|
||||
|
||||
**现状问题:** 规范 7.3 审计日志 仅规定了“管理员的增删改操作必须记录”。这忽略了系统最大的风险面——**内部用户的恶意/异常数据导出**。
|
||||
|
||||
**补充建议:** 必须扩展 7.3 节,强制要求:
|
||||
|
||||
凡是涉及“报表导出”、“Excel 数据下载”、“批量文献导出”的业务接口,必须记录 DataExportLog。记录内容必须包含:userId、IP、导出模块、导出数据量(行数)。这是我们在发生数据泄露时唯一的溯源反制手段。
|
||||
|
||||
### **遗漏 3:API 速率限制与防暴力破解(P1级遗漏)**
|
||||
|
||||
**现状问题:** 规范中未提及接口限流(Rate Limiting)。即使修补了 IDOR,攻击者依然可以利用高并发请求轰炸系统,甚至暴力破解密码或短信验证码。
|
||||
|
||||
**补充建议:** 在 8\. 网络安全规范 或 4\. 认证规范 中加入限流要求:
|
||||
|
||||
1. **登录/验证码接口**:单 IP 或单手机号,每分钟不得超过 5 次,连续失败 5 次锁定 15 分钟。
|
||||
2. **数据查询接口**:采用 Token Bucket 算法限制单用户的高频抓取(如每秒最多 10 次请求)。
|
||||
|
||||
## **二、 架构设计层面的缺失(P2级别改进)**
|
||||
|
||||
### **缺失 1:数据库层的 PII 静态加密(Data at Rest Encryption)**
|
||||
|
||||
**现状问题:** 规范 3\. LLM 调用安全规范 很好地解决了发给大模型时的数据脱敏(Data in Transit)。但是,患者的真实姓名、手机号等 PII 数据在存入我们的 RDS PostgreSQL 时,依然是**明文**的。
|
||||
|
||||
**补充建议:** 规范应增加【数据库层敏感字段加密规范】。要求对于 phone、id\_card 等高敏字段,在 Prisma 层面实现应用层 AES-256-GCM 加密,数据库中只存储密文。
|
||||
|
||||
### **缺失 2:服务端请求伪造(SSRF)防护**
|
||||
|
||||
**现状问题:** 我们的 AI Agent(如 ASL 智能文献工具、IIT 质控工具)未来可能需要根据用户提供的 URL 抓取外部文献或对接医院内部系统,规范中缺乏对 SSRF 的警惕。
|
||||
|
||||
**补充建议:** 增加限制:禁止后端直接请求用户提交的未经验证的 URL;如果必须请求,必须校验目标 IP 不能是内网 IP(如 127.0.0.1,172.16.x.x,10.x.x.x 等)。
|
||||
|
||||
### **缺失 3:依赖库安全审查(SCA)**
|
||||
|
||||
**现状问题:** 规范未提及对 npm 依赖包的安全管理。Node.js 生态中常常出现供应链投毒。
|
||||
|
||||
**补充建议:** 在【部署与网络安全规范】中加入:每次发布前,强制在 CI/CD 流水线中执行 npm audit,出现 High/Critical 级别的漏洞必须修复后才能上线。
|
||||
|
||||
## **三、 针对《安全开发规范(v1.0)》的具体修改建议 (Action Items)**
|
||||
|
||||
为了闭环这套规范,建议技术架构师将以下内容合并进 12-安全开发规范.md:
|
||||
|
||||
1. **新增章节 1.7 文件与 OSS 下载防越权规范**:
|
||||
明确写出生成临时预签名 URL 的代码示例,并强调过期时间 \<= 60s。
|
||||
2. **修改章节 7.3 审计日志**:
|
||||
将标题改为 7.3 管理与高危操作审计日志,把“批量下载、导出 Excel”明确列为必须写日志的高危操作。
|
||||
3. **新增章节 8.5 接口限流与防刷规范**:
|
||||
要求使用 @fastify/rate-limit 中间件,并在代码示例中展示如何给 /api/v2/login 挂载限流器。
|
||||
4. **更新 9\. Code Review 安全检查清单**:
|
||||
增加三项勾选项:
|
||||
* \[ \] 导出/下载接口是否记录了审计日志?
|
||||
* \[ \] 生成的文件下载链接是否为短期时效(\<60s)的预签名 URL?
|
||||
* \[ \] 暴露到公网的对外查询、登录接口是否配置了 Rate Limit?
|
||||
|
||||
## **四、 结论**
|
||||
|
||||
《安全开发规范(v1.0)》是一份优秀的急救指南。如果在 v1.1 版本中补齐上述**OSS文件流转、审计溯源和接口限流**三大盲点,它将成为一份达到医疗行业等保三级要求的、坚不可摧的企业级安全基线文件。
|
||||
Reference in New Issue
Block a user