Files
AIclinicalresearch/docs/08-项目管理/05-技术债务/P0-IDOR水平越权修复与全局LLM脱敏方案.md
HaHafeng 0677d42345 docs(security): 安全评估报告 + IDOR修复方案 + 安全开发规范v1.1
- 新增安全评估报告与P0修复方案(IDOR水平越权~29接口 + 全局LLM脱敏)
- 新增安全开发规范v1.1(9大安全规范 + Code Review检查清单)
- 采纳架构师审查意见补充OSS签名URL、导出审计、接口限流、依赖安全
- 更新开发规范README索引

Made-with: Cursor
2026-03-03 22:20:03 +08:00

32 KiB
Raw Blame History

AI临床研究平台 安全评估报告与修复方案

文档类型: 安全评估报告 + 漏洞修复方案
创建日期: 2026-03-02
评估目标: 北京市卫健委信息处安全审查准备
评估范围: 代码安全、架构安全、阿里云 SAE 部署安全、数据安全与隐私合规
预计修复总工时: P0 约 2-3 天P1 约 1 周P2 约 2 周


目录


第一部分:安全问题总览

P0 — 致命风险(必须立即修复)

1. 生产环境全部凭据明文写在 Git 仓库的文档中

这是最严重的安全问题。 docs/05-部署文档/00-阿里云SAE最新真实状态记录.md 中包含:

  • RDS 数据库密码
  • ACR 镜像仓库密码
  • OSS AccessKey / Secret
  • JWT Secret完整 64 位)
  • 所有 LLM API KeyDeepSeek、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 行:dateFilterrecordIdeventIdstatuspageSizeoffset 等 query 参数直接拼接到原始 SQL,未使用参数化查询
  • backend/src/modules/pkb/services/VectorSearchService.ts 第 125-165 行:kbIddocumentIds 等仅做单引号转义,仍存在注入风险

5. Legacy API 无认证保护

backend/src/index.ts 中以下路由完全没有 authenticate 中间件

  • /api/v1 下的 projectRoutesagentRoutesconversationRoutesknowledgeBaseRoutes
  • /test/platform — 暴露存储、缓存、队列等内部信息

任何人可以无需认证访问这些接口,读写数据。

6. IDOR 水平越权漏洞(~29 个接口)

系统在处理资源查询、下载、操作时,部分模块只校验了 Token 有效性,未校验资源是否属于当前用户。攻击者遍历 ID 即可越权访问全站数据。

模块 受影响接口数 风险等级 涉及数据
SSA 智能统计分析 ~12 用户上传的研究数据、分析代码、SAP 文档
DC Tool-C 数据清洗 ~8 用户上传的 Excel/CSV 临床数据
ASL 文献提取 ~5 提取结果、任务状态、导出文件
IIT CRA 质控 ~4 跨租户项目数据(药企/医院)

详细修复方案见 第三部分

7. LLM 调用普遍缺少 PII 脱敏

仅 DC 模块的 DualModelExtractionService 实现了 maskPII()。以下模块直接将原文发送给第三方 LLM

  • RVW稿件全文可能含作者信息
  • ASL文献全文
  • PKB用户文档全文
  • IITREDCap 记录(可能含患者信息)
  • SSA用户上传的研究数据

卫健委审查重点:临床研究数据发送给外部 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 行:

logger.info('验证码已生成', { phone, code, type, expiresAt });
console.log(`验证码: ${code}`);

生产环境日志会被收集到 SLS验证码可被任何有日志权限的人看到。

11. 前端 Token 存储在 localStorage

frontend-v2/src/framework/auth/api.ts 使用 localStorage 存储 accessToken 和 refreshTokenXSS 攻击可直接读取。

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
  • 基于角色的访问控制RBACSUPER_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 使用 HTTPSsecure: 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)

// backend/src/modules/pkb/services/documentService.ts 第 202-206 行
const document = await prisma.document.findFirst({
  where: {
    id: documentId,
    userId,   // ✅ 强制校验归属
  },

RVW 审稿reviewService.getTaskDetail(userId, taskId)

// backend/src/modules/rvw/services/reviewService.ts 第 386-388 行
const task = await prisma.reviewTask.findFirst({
  where: { id: taskId, userId },   // ✅ 强制校验归属
});

AIA 对话conversationService.getConversationById(userId, id)

// 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 — 会话详情 / 消息 / 执行模式

// backend/src/modules/ssa/routes/session.routes.ts 第 126-129 行
const session = await prisma.ssaSession.findUnique({
  where: { id },           // ❌ 缺少 userId
  include: { messages: true }
});
// 第 149 行
const session = await prisma.ssaSession.findUnique({ where: { id } });  // ❌
// 第 166-173 行 — 消息历史
const messages = await prisma.ssaMessage.findMany({
  where: { sessionId: id },  // ❌ 未校验 session 归属
  orderBy: { createdAt: 'asc' }
});

(2) analysis.routes.ts — 上传 / 计划 / 执行 / 下载代码

// 第 54-58 行 — upload
await prisma.ssaSession.update({
  where: { id },              // ❌ 缺少 userId
  data: { dataOssKey: storageKey }
});
// 第 73-77 行 — plan
const session = await prisma.ssaSession.findUnique({
  where: { id },              // ❌
  select: { dataSchema: true }
});
// 第 216-219 行 — execute
const session = await prisma.ssaSession.findUnique({
  where: { id }               // ❌
});
// 第 334-338 行 — download-code
const session = await prisma.ssaSession.findUnique({
  where: { id },              // ❌
  select: { title: true, createdAt: true }
});

(3) consult.routes.ts — 咨询聊天 / 生成 SAP / 下载 SAP

// 第 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
  });
// 第 142 行 — generate-sap / 第 168 行 — download-sap
// 同样未校验 session 归属

(4) chat.routes.ts — 对话历史 / 会话元信息(第 207-253 行)

修复方案

// 统一修复模式:所有 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 归属,再查消息:

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 行)
// SessionController.ts 第 131 行
const session = await sessionService.getSession(id);  // ❌ 未传 userId

// SessionService.ts 第 152-154 行
const session = await prisma.dcToolCSession.findUnique({
  where: { id: sessionId },                            // ❌ 缺少 userId
});

受影响接口:getSessiongetPreviewDatagetFullDatadeleteSessionexportDataheartbeatgetUniqueValuesgetStatus

修复方案

// 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
// 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
  });
// ExtractionController.ts 第 119-136 行 — getResultDetail
const result = await prisma.aslExtractionResult.findUnique({
  where: { id: request.params.resultId },  // ❌ 未校验 result → task → userId
  include: { task: { ... } },
});

受影响接口:getTaskStatusgetResultsgetResultDetailreviewResultexportTaskResultslistDocuments

修复方案

// 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
// iitProjectService.ts 第 117-118 行
async getProject(id: string) {
  const project = await this.prisma.iitProject.findFirst({
    where: { id, deletedAt: null },   // ❌ 缺少 tenantId

修复方案

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 ManagerCRA 质控) 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 行):

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

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

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 本身(以便运行时切换开关)。

步骤 3DC 模块内的 maskPII 改为调用通用服务

修改 backend/src/modules/dc/tool-b/services/DualModelExtractionService.ts

import { PiiMaskingService } from '../../../../common/services/piiMaskingService';

// 删除 private maskPII 方法,改为:
const maskedText = PiiMaskingService.maskText(input.text);

步骤 4添加环境变量开关

backend/src/config/env.ts 中增加:

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.tsVectorSearchService.ts 中的原始 SQL 改为参数化查询
  • IDOR 修复SSA、DC Tool-C、ASL、IIT 四个模块的水平越权漏洞(详见第三部分)
  • 全局 LLM 脱敏:通过 LLMFactory Proxy 实现全模块脱敏(详见第四部分)
  • CORS 白名单:将 origin: true 改为生产域名白名单
  • 关闭/保护 Legacy 路由:为无认证的 Legacy API 添加认证或下线
  • 关闭 /test/platform 路由:生产环境禁用测试端点
  • Nginx 安全头:添加 X-Frame-OptionsX-Content-Type-OptionsX-XSS-ProtectionReferrer-Policy
  • 验证码日志脱敏:生产环境不打印验证码
  • 错误响应脱敏:生产环境不返回 stack trace

第二阶段加固措施1-2 周)

  • HTTPS 配置CLB 配置 SSL 证书,开启 443 端口HTTP 强制跳转 HTTPSNginx 添加 HSTS
  • 速率限制:使用 @fastify/rate-limit 对登录/验证码/API 添加限流
  • 登录安全:实现 5 次失败锁定 15 分钟;记录 IP 和 User-Agent
  • 密码策略强化:长度 >= 8包含大小写 + 数字 + 特殊字符
  • PII 加密:对 phonenameemail 实施应用层 AES 加密存储
  • 管理员审计日志:启用 admin_operation_logs 表写入
  • 数据库 SSLDATABASE_URL 添加 sslmode=require
  • extraction_service 非 rootDockerfile 添加 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 目标)

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

总结

当前安全等级评估:中低

最紧迫的问题是凭据泄露(文档中明文记录所有生产密钥并提交到 GitSQL 注入漏洞和 IDOR 水平越权~29 个接口无归属校验)。这些问题如果被利用,可能导致数据库被入侵、全部用户数据泄露。

卫健委审查重点关注项

  1. 数据传输加密HTTPS— 当前 CLB 仅 HTTP
  2. 个人信息保护 — PII 明文存储,且未脱敏直接发送给第三方 LLM
  3. 访问控制 — IDOR 水平越权可导致任意用户数据泄露
  4. 临床数据发送给第三方 LLM 的合规性
  5. 审计日志完整性
  6. 数据备份与恢复能力

建议按 P0 → P1 → P2 优先级逐步修复,第一阶段的紧急修复可在 2-3 天内完成。


附录 ALLM 调用点完整清单

以下为 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. 《等保三级自评清单》 — 卫健委可能要求