ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MCP应用安全新防线:CI/CD中的自动化红队测试实战

MCP应用安全新防线:CI/CD中的自动化红队测试实战 做 MCP 应用交付有一段时间了最大的感受是功能上线越来越快但心里越来越没底。MCPModel Context Protocol模型上下文协议应用本质上是一层让模型和外部工具、数据源对话的胶水协议越灵活攻击面就越不像传统 API 那样边界清晰。发布前专门请安全团队集中打一次红队流程上没问题可版本一周发三次根本等不到。所以这段时间我把自动化红队测试直接塞进了 CI/CD 流水线让每次构建、每次合并请求都先过一遍攻击剧本。这篇文章把背后的思路、场景设计、工具选型和具体配置完整记录下来给做 MCP 应用研发、DevOps 和安全运营的同学一个可落地的参考。1. 为什么 CI/CD 里必须有一道“红队防线”1.1 MCP 应用的安全边界比传统 API 更模糊MCP 是一个开放协议用来连接 AI 模型与外部工具、数据源很多人把它叫做“AI 应用的 USB-C 接口”。一个 MCP 应用通常包含 host模型宿主、client协议客户端、server工具提供方和 resource数据源。模型通过 MCP server 暴露的工具来读文件、查数据库、发请求、执行脚本这种设计让应用能力边界一下子扩大了很多但同时也把传统 API 时代“一个接口管一个功能”的清晰边界打碎了。传统 API 的安全核心关注点集中在认证、鉴权、参数校验、限流、防注入这些层面安全团队可以靠网关、WAF、API 扫描器覆盖大部分风险。但 MCP 应用额外引入了几个很难用传统手段覆盖的攻击面提示注入外部内容可以作为工具返回值回传给模型攻击者可以把恶意指令藏在数据里诱导模型忽略系统指令、执行敏感操作。工具滥用模型根据用户的自然语言意图去调用工具攻击者不需要直接操作工具只要让模型“以为”某个动作是合理的就可能调起删除、转账、反弹文件等危险能力。上下文污染MCP server 返回的数据带有一段恶意内容污染了模型之后的判断导致后续工具调用错乱。供应链风险MCP 生态里大量第三方 server 实现质量参差不齐协议解析、标准库选择都可能携带自己的漏洞。这些攻击不一定在 HTTP 层留下明显特征。网关看到的是正常请求但模型已经在语义层面被“带偏”了。因此只靠基础 Web 扫描远远不够必须用红队思维去模拟攻击者如何组合利用这些环节。1.2 红队测试从“集中打一次”变成“每次发布都打”传统红队测试有一个天然矛盾人工深度渗透测试质量高但周期长、成本高。通常一个版本上线前安全团队排期做一次发现问题后修复、复测一来一回就是一两周。而 MCP 生态迭代非常快今天依赖的一个 SDK 明天就升级了昨天还安全的协议实现今天可能因为某个依赖升级多出新的漏洞。这种节奏下人工红队只能覆盖“关键发布”覆盖不了持续交付的日常频率。把自动化红队测试嵌入 CI/CD 流水线不是要替代人工红队而是把红队专家的攻击经验“脚本化、场景化、门禁化”。每次开发提交代码流水线自动构建一个隔离的测试环境然后自动跑一遍攻击剧本。好处非常明显回归性同一个漏洞修完之后下次构建自动验证不会再犯。可见性每个 MR 或 commit 都带一份安全测试报告问题暴露在最早阶段修复成本最低。可量化安全团队可以基于流水线失败率、漏洞等级分布持续评估应用安全质量。可追溯红队报告和代码版本绑定出了事回看是哪一个 commit 引入的非常直观。我称它为“最后一道防线”是因为它处在代码评审、静态扫描、镜像扫描之后是发布前离生产最近的一道自动闸门。前面几道防线都过了代码逻辑、依赖版本、密钥泄露都检查过但应用真正跑起来后攻击者还是能通过组合调用工具造成破坏。自动化红队测试模拟的正是这种“应用已运行”状态下的攻击路径。2. 流水线里的自动化红队测试怎么设计2.1 设计原则左移、分层、fail-fast把红队测试塞进流水线不能简单加一个 stage 就完事需要先定设计原则。我自己的经验是三条左移、分层、fail-fast。左移能在代码提交阶段发现的问题不要拖到动态测试。源码级别的密钥泄露、不安全依赖、硬编码 token用 Gitleaks、Semgrep、Trivy 在构建前跑掉几秒钟就能反馈。左移不是让红队阶段空转而是把能低成本发现的问题过滤掉让红队脚本聚焦真正需要模拟攻击的动态行为。分层整个流水线按“静态扫描 → 镜像扫描 → 动态红队 → 门禁”四层排布。每一层有不同的输入和输出前一层失败可以直接阻断不用等后面。比如源码里已经有明文密钥那镜像扫描和动态红队都不用跑直接让开发者回去改。fail-fast流水线中一旦发现高危或严重问题立刻中断不要等所有测试跑完。这样做一方面节省 CI 资源和时间另一方面让开发第一时间看到问题而不是等 20 分钟后拿到一封“这里不行那里也不行”的总结报告。GitHub Actions 里一般通过 exit code 控制红队脚本返回非 0 就会终止 job。整个流水线看起来像是提交代码 → 静态分析 → 构建镜像 → 漏洞扫描 → 启动临时环境 → 红队攻击剧本 → 生成报告 → 门禁判定 → 放行/阻断。每一环都尽量小、快、可观测。2.2 测试范围与攻击场景映射自动化红队和价值最高的地方是把常见攻击场景固化成可重复执行的用例。对 MCP 应用来说下面这些场景是我认为至少需要覆盖的攻击场景针对环节自动化测试方式常见工具/手段提示注入模型-工具通信层构造包含恶意指令的工具返回值验证模型是否泄露秘密或执行危险操作自研红队脚本 Mock 模型输出工具参数篡改工具调用处理器对 JSON-RPC 请求中的参数做 fuzz尝试路径穿越、SQL 注入、命令注入自研脚本 Nuclei 模板SSRFMCP server 外呼能力准备一个内网探测服务让 MCP 工具请求该地址观察响应ZAP 自定义规则敏感信息泄露仓库、镜像、运行时扫描源码、镜像层、健康检查端点、错误日志Gitleaks、Trivy、ZAP资源耗尽请求频率与并发控制发送大量请求观察超时、并发限制和内存占用自研压力脚本 资源监控例如一个 MCP server 提供read_file工具很多人会直接写成open(path).read()。看似无害但攻击者完全可以把 path 传成../../../../etc/passwd。这种问题在传统 API 输入校验中很常见放到 MCP 里因为工具调用由模型间接发起更容易被忽略。红队脚本要做的就是像攻击者一样直接调用工具并断言返回值是否泄露了不该泄露的内容。再比如工具参数篡改。MCP 协议基于 JSON-RPCcall_tool请求里带一个参数对象。攻击者可以在参数里塞; cat /etc/passwd之类的 payload如果 server 端直接把参数拼进 shell 命令就是命令注入。这一层测试用简单的 fuzz 脚本就能覆盖关键是测试数据要贴近业务实际。2.3 工具选型和流水线插桩点市面上安全测试工具很多但不是每个都适合 CI/CD。我的选型标准很简单必须支持命令行非交互模式能在无头环境跑。输出必须是结构化格式比如 JSON、SARIF、JUnit XML方便流水线解析。支持自定义规则或插件因为 MCP 协议层问题通用工具往往覆盖不到。社区活跃、许可证友好避免后期因为授权问题换轮子。我目前用的组合是Gitleaks 负责密钥扫描Semgrep 做静态分析Trivy 扫镜像漏洞Nuclei 跑模板化漏洞检测OWASP ZAP 做传统 Web 层 DAST最后再用一个自研 Python 红队客户端跑 MCP 协议级攻击剧本。这个组合里自研脚本是核心其他工具负责外围。插桩点的位置也很有讲究。Gitleaks 和 Semgrep 放在 checkout 之后、构建之前Trivy 放在镜像构建之后ZAP 和自研红队脚本放在临时环境启动之后最终的报告解析和门禁判定放在发布前一步。每一步的输出都以 artifact 形式保留方便回看。3. 实操把红队测试压进 CI 流水线3.1 先搭一个“故意留洞”的 MCP 测试环境要验证流水线中的红队测试最好有一个可控的脆弱应用。我准备了一个最小 MCP server它暴露了read_file和fetch_url两个工具分别模拟路径穿越和 SSRF 风险点。生产环境千万别这么写这是用来当靶子的。# server.py from mcp.server.fastmcp import FastMCP mcp FastMCP(demo-server) mcp.tool() def read_file(path: str) - str: with open(path, r, encodingutf-8) as f: return f.read() mcp.tool() def fetch_url(url: str) - str: import requests return requests.get(url, timeout5).text if __name__ __main__: mcp.run()为了方便在 CI 里稳定启动我用一个简单的 Dockerfile 把它打成容器镜像并在流水线里作为 service 容器启动。启动后MCP server 会监听一个端口红队脚本通过 MCP Python SDK 连接它并调用工具。FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY server.py . EXPOSE 3000 CMD [python, server.py]这里的requirements.txt至少要包含mcp或fastmcp以及requests。实际项目可能还要接 Redis、数据库、外部 API那就在测试环境里一并拉起依赖原则是尽可能贴近生产但绝不直接连生产数据。3.2 编写攻击剧本三个必须跑的场景下面这段代码是我在实际项目里用的红队脚本核心逻辑基于 MCP Python SDK。需要说明的是不同版本的 SDK 导入路径会有差异但核心逻辑是一样的启动子进程、建立会话、调用工具、断言结果。import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client SERVER_CMD [python, server.py] async def run_scenario(name, check): server StdioServerParameters(commandSERVER_CMD[0], argsSERVER_CMD[1:]) async with stdio_client(server) as (read, write): async with ClientSession(read, write) as session: await session.initialize() result_text await check(session) print(f[{name}] {result_text}) async def scenario_path_traversal(session): # 尝试通过 read_file 读取 /etc/passwd result await session.call_tool(read_file, {path: ../../../../etc/passwd}) text result.content[0].text if result.content else if root: in text: return FAIL: 路径穿越读取到 /etc/passwd return PASS: 路径穿越被拦截 async def scenario_ssrf(session): # 访问内网敏感服务正常环境不应成功 result await session.call_tool(fetch_url, {url: http://127.0.0.1:9090/secret}) text result.content[0].text if result.content else if secret_token in text: return FAIL: SSRF 访问到内网服务 return PASS: SSRF 被拦截 async def main(): await run_scenario(path_traversal, scenario_path_traversal) await run_scenario(ssrf, scenario_ssrf) asyncio.run(main())除这两个场景外命令注入也值得写。比如一个工具把参数拼到 shell 里执行红队脚本可以传; cat /etc/passwd #看返回内容里有没有 passwd 文件内容。这些脚本的判定标准不是“有没有报错”而是“攻击是否成功”。只要攻击成功即使 server 没有崩溃也要判定为失败因为攻击者已经拿到敏感信息了。另外要特别说明一点提示注入类场景如果要自动化最好准备一个 Mock 模型或固定输出而不是依赖真实大模型。真实模型行为不稳定一次说人话一次不说人话不适合做 CI 里的稳定断言。我通常会预先定义一段“系统提示词”然后模拟用户输入注入指令断言系统是否泄露某个预置的 secret 字符串。3.3 在 GitHub Actions 中串联红队阶段流水线我选了 GitHub Actions因为它对 artifact、service container、branch protection 的集成都是原生的改动也小。下面是一个精简但完整的 workflow 示例。name: MCP-RedTeam on: pull_request: branches: [ main ] push: branches: [ main ] jobs: security-gates: runs-on: ubuntu-latest services: mcp-server: image: ghcr.io/yourorg/mcp-demo:latest ports: - 3000:3000 options: - --health-cmd curl -f http://localhost:3000/health || exit 1 --health-interval 5s --health-timeout 3s --health-retries 5 steps: - name: Checkout uses: actions/checkoutv4 - name: Setup Python uses: actions/setup-pythonv5 with: python-version: 3.11 - name: Install dependencies run: pip install -r requirements-dev.txt - name: Secret scan run: gitleaks detect --redact --verbose - name: Static analysis run: semgrep --config auto --json -o semgrep.json - name: Image vulnerability scan run: trivy image ghcr.io/yourorg/mcp-demo:latest --severity HIGH,CRITICAL --exit-code 1 - name: Run MCP red team scenarios run: python tests/redteam/run_redteam.py - name: OWASP ZAP baseline run: | docker run --rm -t ghcr.io/zaproxy/zaproxy zap-baseline.py \ -t http://localhost:3000 \ -r zap_report.html - name: Upload security reports if: always() uses: actions/upload-artifactv4 with: name: security-reports path: | semgrep.json gitleaks.json zap_report.html redteam_results.json这个 workflow 里有两个细节要注意。第一mcp-server作为 service 启动GitHub Actions 会为它分配一个稳定的 service 主机名所以脚本里连接地址不能写localhost要写成mcp-server:3000。但如果 MCP server 是通过 stdio 模式启动那就不需要 service直接把SERVER_CMD指向构建好的入口文件即可。第二trivy image那一步我加了--exit-code 1表示只要发现高危或严重漏洞就直接阻断这是第一道硬门禁。关于并发参数的估算我举一个实际例子。假设红队脚本中有一个“资源耗尽”场景需要在目标服务上模拟高频工具调用。如果期望总请求数是 600希望 60 秒跑完单次请求的平均延迟是 200ms那么单个连接能提供的吞吐是 5 req/s要跑到 10 req/s 就需要至少 2 个并发连接。考虑到 CI runner 的 CPU 和网络开销我会再乘一个安全系数比如 5也就是 10 个并发比较稳妥。target_qps 10 single_thread_qps 1 / 0.2 # 5 req/s concurrency int(target_qps / single_thread_qps * 5) concurrency min(concurrency, 50) # 上限保护这段计算看起来简单但实际很管用。并发设太大会把测试环境打挂产生一堆和漏洞无关的超时设太小又测不出资源耗尽问题。每个团队可以根据自己的服务规格调整系数。3.4 红队结果门禁如何才算“过了”测试跑完只是第一步关键是怎么把结果变成流水线的门禁。我的做法是让红队脚本输出一个统一的 JSON 结果文件然后由一个独立的gate.py来判定是否阻断。# gate.py import json import sys BLOCKING_SEVERITIES {critical, high} def load_results(path: str): with open(path, r, encodingutf-8) as f: return json.load(f) def should_block(results: dict): for finding in results[findings]: if finding[severity].lower() in BLOCKING_SEVERITIES: return True, finding return False, None if __name__ __main__: results load_results(sys.argv[1]) blocked, finding should_block(results) if blocked: print(fBLOCKED: {finding[title]} ({finding[severity]})) sys.exit(1) print(SECURITY GATE PASSED)门禁策略可以根据团队承受风险的能力调整严重级别是否阻断说明Critical是立即阻断修复并复测才能继续High是阻断发布允许走安全豁免流程Medium否展示到报告中不阻断Low否记录到历史定期复盘这里一定要处理误报。自动化工具误报率不可能为零。我的做法是维护一个redteam-allowlist.json里面记录规则 ID、路径、原因和过期时间。超过过期时间的白名单需要重新评估避免某个误报被无限期忽略。{ allowlist: [ { id: semgrep-rule-003, path: tests/redteam/fixtures/, reason: 测试专用脆弱代码非生产逻辑, expires: 2025-12-31 } ] }白名单是一种必要的管理手段但不能把它当成偷懒工具。每加一条白名单都应该有明确的负责人和过期时间否则三个月后这条白名单本身就是一个风险点。4. 常见问题与排查技巧4.1 测试环境不稳定导致误报自动化红队最容易碰到的坑就是“服务还没起来攻击脚本已经跑了”。明明服务本身没问题结果一上来就超时然后流水线红了一大片。排查思路很直接加健康检查在红队脚本开头做 readiness 探测。import time import requests def wait_for_ready(url: str, timeout: int 60): deadline time.time() timeout while time.time() deadline: try: resp requests.get(url, timeout2) if resp.status_code 200: return except requests.RequestException: pass time.sleep(2) raise RuntimeError(MCP server not ready within 60s)在 GitHub Actions 的 service 配置里也可以直接用--health-cmd和--health-retries让容器自己报告健康状态这样后续步骤会等 service 就绪后再启动。对本地调试来说我一般先手动起一个服务跑一遍红队脚本看能不能稳定复现再挂到流水线里。4.2 红队脚本拖慢发布怎么办安全测试必然增加流水线时长但可以通过几个手段把影响降到最低。第一增量选择攻击场景。如果这次改动只涉及文件读取工具那 SSRF 和命令注入场景就不需要跑用 git diff 计算受影响模块动态决定执行哪些脚本。第二并行运行不互相依赖的检查比如 Semgrep、Gitleaks、Trivy 可以放在三个独立 job 里最后汇总结果。第三给红队阶段设置时间上限比如 300 秒超过就直接判定失败或警告避免某个请求一直挂起把流水线拖死。对增量选择我在 CI 里常用这样一段逻辑changed_files$(git diff --name-only origin/main...HEAD) if echo $changed_files | grep -q mcp_tools/file_reader.py; then echo run_file_reader_scenariotrue $GITHUB_ENV fi然后 workflow 的下一步判断这个变量只有变量为 true 时才跑对应用例。这个优化在项目初期意义不大但 MCP 工具数量超过 20 个之后全量跑一遍的攻击剧本可能从 5 分钟膨胀到 30 分钟增量选择几乎是必选项。4.3 工具输出格式不统一Gitleaks 输出 JSONSemgrep 可以输出 SARIFZAP 输出 HTML自研脚本输出自定义 JSON。一堆格式混在一起门禁逻辑没法统一处理。我建议把外部工具的结果统一转换成 SARIF 格式或者转成自己定义的标准 JSON schema。GitHub Security tab 原生支持 SARIF 上传上传之后可以直接在代码扫描告警里看到 Semgrep、Gitleaks 等工具的结果省去维护一个独立安全平台的成本。转换逻辑并不复杂就是遍历工具输出把漏洞标题、路径、行号、严重级别映射到 SARIF 的results数组。如果团队已经有安全数据平台也可以按平台的 data model 转换重点是格式必须先统一后面的门禁才好写。4.4 凭据安全与最小权限在 CI 里跑红队最怕的是把生产凭据带到测试环境。很多 MCP 应用需要数据库密码、API token开发者在配置测试环境时图省事直接把生产密钥写进 GitHub Secrets又或者更糟糕直接写进 Dockerfile。这等于给红队测试提供了一个“官方泄露通道”。我的处理方式测试环境一律使用专门生成的 mock 凭据和开发、生产数据完全隔离。环境变量从 CI Secrets 注入不写进镜像和代码库。红队脚本本身不包含任何真实凭据所有敏感输入都从环境变量读取。如果 MCP server 需要访问云服务给测试账号配置最小权限策略只允许访问测试桶、测试数据库。CI 的临时环境本身也要限制网络边界。GitHub Actions 的 service 容器默认只在 job 网络里暴露千万不要映射到公网。曾经有团队为了让本地调试方便把 MCP server 的端口直接暴露到宿主机结果被扫描器扫到相当于把肉送上门。4.5 红队测试自身的风险边界自动化红队脚本也有自己的安全边界这一点很容易被忽略。脚本里发起攻击请求的目标地址必须严格限制在测试环境网段如果因为配置错误打到了生产环境后果会比不测更严重。我在脚本里加了一道硬校验所有请求的 URL 目标必须以测试环境域名或测试网段白名单开头一旦发现请求目标不是预期地址直接抛异常并终止测试。ALLOWED_TARGETS [http://localhost, http://mcp-server, http://127.0.0.1] def assert_safe_target(url: str): if not any(url.startswith(prefix) for prefix in ALLOWED_TARGETS): raise RuntimeError(f目标地址被拒绝: {url})同时红队测试最好在独立临时的代码分支上触发而不是直接对生产分支做主动攻击。如果团队规模不大可以只在 pull request 阶段跑红队避免每次 push 都扫一遍导致消耗翻倍。记住自动化红队的目标是降低风险不是给自己创造新的风险。最后说说我个人的体会。以前觉得红队是安全团队的事CI/CD 是研发的事中间隔着一条河。把自动化红队嵌进流水线之后两边反而找到了共同的抓手。MCP 这个协议还在快速迭代今天测过的攻击面明天可能因为一个依赖升级又多出新的变体所以这套红队脚本不是写完就完事的我建议每个迭代周期至少回头更新一次攻击剧本把线上真实出现过的告警、安全公告里的新鲜案例都沉淀进去。另一个小技巧是把每次红队报告和 commit hash 绑定归档几个月后要复盘“某个漏洞是什么时候引入的”翻流水线产物比翻聊天记录靠谱太多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进