Files
MeshSate/docs/接口文档.md
zhaolei 16f134e328 feat: MeshSafe 文件摆渡安全卫士 v1.0 初始提交
核心功能:
- 6 层检测管线: L0 结构分析 → L1 ClamAV → L2 文档深度检测 → L3 关键词匹配 → L4 YARA → L5 PE/二进制
- FastAPI + PostgreSQL + Redis + Docker Compose 架构
- 客户端 HMAC-SHA256 签名鉴权 + 限流 + 幂等性
- 控制台 UI (登录/Dashboard/诊断中心/策略管理/任务列表/测试中心/产品介绍页)
- 三层诊断定时器 (error_scan 30s / db_aggregate 30s / container_check 5min)

并发与稳定性修复:
- 诊断定时器 Redis 时间槽位锁 (多 worker 同一槽位只有一个执行)
- JSONL 存储 fcntl 跨进程排他锁 + 容错解码 + 原子重写
- 移除 SQLite fallback, 统一 PostgreSQL
- API_CLIENTS 脱敏为环境变量, .env 已 .gitignore
2026-10-28 19:07:11 +08:00

1323 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MeshSafe 文件安全检测服务 · 对外接口文档
| 项目 | 内容 |
|---|---|
| 文档版本 | v1.1(2026-10-27 对齐当前代码实现) |
| 接口版本 | `/api/v1` |
| 适用系统 | 光闸内外网文件摆渡系统 |
| 保密级别 | **内部交付文档**,请勿公开传播 |
---
## 目录
1. [服务概述](#1-服务概述)
2. [接入准备](#2-接入准备)
3. [认证与签名规范](#3-认证与签名规范)
4. [接口清单](#4-接口清单)
5. [数据模型](#5-数据模型)
6. [检测规则清单](#6-检测规则清单)
7. [错误处理](#7-错误处理)
8. [接入示例](#8-接入示例)
9. [附录](#9-附录)
---
## 1. 服务概述
### 1.1 能力范围
MeshSafe 是部署在内网隔离区的**文件安全检测专用服务**,通过标准 HTTP 接口接收文件并返回结构化检测结论。
**检测覆盖的风险类型:**
| 类型 | 说明 |
|---|---|
| 恶意代码 | 病毒、木马、勒索软件、挖矿程序、后门 |
| 主动执行风险 | VBA 宏、DDE 自动执行、双扩展名伪装、文档内嵌可执行对象、PDF 脚本 |
| 容器逃逸类构造 | 压缩炸弹、路径穿越、超深嵌套、文件名伪装 |
| 策略违规 | 文件类型不在允许清单、文件超限、加密文件 |
**服务不做的事:**
- 不修改、不改写被检测文件
- 不返回解包后的文件内容
- 不做内容分类分级与敏感信息识别
- 不做杀毒处置(隔离、删除由摆渡系统按 `action` 执行)
### 1.2 部署形态
服务部署在内网隔离区,**运行期不访问公网**。病毒库与检测规则通过单向导入方式离线更新。
接入方式:调用方与检测服务之间走 HTTPS,证书由内部 CA 签发。
### 1.3 同步与异步
| 模式 | 何时使用 | 返回 |
|---|---|---|
| **同步** | 文件较小(默认 ≤ 512 MB),需要立即拿到结论 | 请求内返回完整结论 |
| **异步** | 文件较大,或策略要求走沙箱深检 | 返回 `task_id`,需轮询查询 |
两种模式的**请求完全相同**,服务根据文件大小与策略自动选择。异步模式下调用方轮询 `GET /api/v1/scans/{task_id}` 即可。
---
## 2. 接入准备
### 2.1 申请凭证
向系统管理员申请一组凭证,获得:
| 字段 | 说明 |
|---|---|
| `client_id` | 调用方标识,如 `duandao-system-a` |
| `secret` | 调用方密钥,用于签名,**请勿泄露** |
多套调用方请分别申请,不要共用同一组凭证 —— 审计日志会按 `client_id` 区分调用来源。
### 2.2 凭证安全要求
- `secret` 仅存储在调用方服务端,**禁止**硬编码在客户端或前端代码中
- 建议通过环境变量或密钥管理服务注入
- 怀疑泄露时联系管理员轮换(轮换后旧凭证立即失效)
### 2.3 网络连通性
| 源 | 目标 | 端口 |
|---|---|---|
| 摆渡系统服务端 | 检测服务 | 443 |
---
## 3. 认证与签名规范
> **本章是全文最重要的部分。** 请完整阅读后再开始对接。
### 3.1 为什么需要签名
本服务面向内外网数据交换场景,接口可能被多个系统调用。为防止未授权调用、请求重放、以及检测结论被篡改,所有请求必须携带 HMAC 签名,所有响应也提供独立签名供验签。
### 3.2 请求头
| 头部 | 必填 | 说明 |
|---|---|---|
| `X-MS-Client-Id` | 是 | 调用方标识 |
| `X-MS-Timestamp` | 是 | Unix 秒级时间戳 |
| `X-MS-Nonce` | 是 | 随机串,32 位小写十六进制,**同一次请求不可重复使用** |
| `X-MS-Body-SHA256` | 是 | 请求体原始字节的 SHA256,小写十六进制 |
| `X-MS-Signature` | 是 | 请求签名,小写十六进制 |
### 3.3 待签名串构造
各部分以 `\n` 分隔,**末尾无换行符**:
```
METHOD \n PATH \n CANONICAL_QUERY \n BODY_SHA256 \n TIMESTAMP \n NONCE \n CLIENT_ID
```
| 部分 | 取值规则 |
|---|---|
| `METHOD` | 大写,如 `POST` |
| `PATH` | 请求路径,不含域名、不含查询串。例:`/api/v1/scans` |
| `CANONICAL_QUERY` | 查询参数按 key 升序排列,`k=v` 用 `&` 连接;无参数时为空字符串 |
| `BODY_SHA256` | 请求体原始字节的 SHA256,小写十六进制 |
| `TIMESTAMP` | Unix 秒级时间戳字符串 |
| `NONCE` | 与请求头一致 |
| `CLIENT_ID` | 与请求头一致 |
**示例**(`POST /api/v1/scans`,无查询参数,空请求体):
```
POST
/api/v1/scans
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1700000000
0123456789abcdef0123456789abcdef
test-client
```
注意 `PATH` 与 `CANONICAL_QUERY` 之间有一行是空行 —— 因为查询参数为空。
### 3.4 签名密钥派生
**不要直接用 `secret` 做 HMAC 密钥**,必须先派生:
```
signing_key = HKDF-SHA256(
ikm = secret,
salt = client_id,
info = "meshsafe/api/v1/request-signing",
len = 32
)
```
```
signature = HMAC-SHA256(signing_key, 待签名串) → 小写十六进制
```
派生用途隔离的意义:即使请求签名密钥泄露,攻击者也无法伪造响应签名(响应使用独立的派生标签)。
> 大多数语言的标准库没有 HKDF,需要自行实现(约 15 行代码),示例见 §8。
### 3.5 文件上传的 body 哈希
上传文件时,请求体是 `multipart/form-data`,包含**随机 boundary**,因此:
**`X-MS-Body-SHA256` 必须对最终发送的完整 HTTP 请求体字节计算**(即 `Content-Type` 头之后的所有字节,含 boundary)。
正确做法:
```python
# 构造请求体后,对 bytes 计算哈希
body = build_multipart_body(file_bytes, filename)
body_hash = hashlib.sha256(body).hexdigest() # ← 对最终字节计算
headers = sign(secret, client_id, "POST", "/api/v1/scans", body=body)
# 然后用完全相同的 body 字节发送
requests.post(url, data=body, headers={**headers, "Content-Type": content_type})
```
**常见错误**:用 `files=` 参数让 HTTP 库自动构造 multipart —— 这样你无法预知最终 body 字节,也就无法正确计算哈希。**必须手工构造 body。**
### 3.6 时间戳与防重放
| 约束 | 值 |
|---|---|
| 允许时钟偏差 | ±300 秒 |
| nonce 复用 | 直接拒绝 |
服务端会记录已使用的 nonce。若因网络重试需要重发同一请求,**必须更换 nonce 与时间戳**。
### 3.7 响应签名与验签
响应头中包含:
| 头部 | 说明 |
|---|---|
| `X-MS-Response-Timestamp` | 响应生成时间戳 |
| `X-MS-Response-Signature` | 响应签名,小写十六进制 |
```
response_key = HKDF-SHA256(
ikm = secret,
salt = client_id,
info = "meshsafe/api/v1/response-signing",
len = 32
)
payload = SHA256(响应体原始字节) || X-MS-Response-Timestamp
signature = HMAC-SHA256(response_key, payload) → 小写十六进制
```
> **验签是调用方的强制责任。** 不验签意味着你无法确认结论未被篡改,也无法确认响应确实来自本服务。生产对接必须实现验签。
验签失败时**必须拒绝使用该结论**,并记录告警。
### 3.8 校验向量
以下向量可直接用于您的单元测试,验证签名实现是否正确。
| 参数 | 值 |
|---|---|
| `secret` | `test-secret` |
| `client_id` | `test-client` |
| `method` | `POST` |
| `path` | `/api/v1/scans` |
| `query` | 空 |
| `body` | 空 |
| `timestamp` | `1700000000` |
| `nonce` | `0123456789abcdef0123456789abcdef` |
期望结果:
| 项 | 期望值 |
|---|---|
| `body_sha256` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` |
| `signing_key`(hex) | `e2c60ab7169f72b364aa0c17a1d96022c5ae1b1852ec8a2ad3c5d319a46f6e2f` |
| `signature`(hex) | `9d0bf5d6cf8e395a122d280d34199448807c5ed4604ae7555cc7ecb8d863654b` |
> **同时提供响应签名校验向量**(用于验证 `response-signing` 密钥派生和 HMAC 实现):
>
> | 参数 | 值 |
> |---|---|
> | `response_key`(hex) | `5059871d01a76283d43579cf50c00646f5d21b8bccc6123db32aebd9c741054e` |
> | 假设响应体 | 空(`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`) |
> | `response_timestamp` | `1700000000` |
> | `response_signature` | `79818ae5f2dd07d74713e67251d823b0be155cce17699a568aef7b233588f84e` |
>
> > 待签名串 = `SHA256(响应体).hex() + response_timestamp` → 对 response_key 做 HMAC-SHA256。
> 若上面的 signature 与您的实现算出来不一致,说明 HKDF 或 HMAC 的实现有偏差(常见原因是 HKDF 的 counter 从 0 开始而非 1,或 HMAC 输出未转小写 hex)。
> 同时可使用在线调试台 `/console/signing` 现场生成签名进行比对。
### 3.9 认证失败的表现
所有认证失败统一返回 HTTP 401,响应体:
```json
{
"detail": "认证失败"
}
```
**不会**区分"client_id 不存在"与"签名错误",以防凭证枚举。
---
## 4. 接口清单
### 4.1 POST /api/v1/scans
提交文件进行安全检测。
**请求**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | binary | 是 | 待检测文件 |
| `channel` | string | 否 | 业务通道标识,默认 `default`。本期为全局策略,通道仅用于日志追溯 |
| `priority` | string | 否 | `normal`(默认)/ `high`,仅影响异步任务的排队顺序 |
**Content-Type**:`multipart/form-data`
**响应 200(同步完成)**
```json
{
"task_id": "3f8a9c2e1b7d4e6f8a0b2c4d6e8f0a1b",
"verdict": "malicious",
"score": 85.0,
"action": "reject",
"reason": "检测到 VBA 宏 + DDEAUTO 自动执行",
"status": "completed",
"file": {
"sha256": "a1b2c3...",
"md5": "d4e5f6...",
"size": 284915,
"real_type": "Microsoft Word 2007+",
"real_ext": ".docx",
"declared_ext": ".docx",
"filename": "起诉状.docx"
},
"findings": [
{
"engine": "document",
"layer": "L2",
"severity": "critical",
"rule": "DDEAutoExecution",
"detail": "检测到 DDE/DDEAUTO 字段:DDEAUTO c:\\windows\\system32\\cmd.exe",
"extracted_path": "word/settings.xml",
"weight": 65.0,
"meta": {}
}
],
"extracted": [
{ "path": "word/vbaProject.bin", "filename": "vbaProject.bin", "size": 10240, "depth": 1, "sha256": "" }
],
"triggered": [
{ "type": "rule_override", "rule": "DDEAutoExecution", "action": "reject", "engine": "document" }
],
"meta": {
"scan_duration_ms": 1840,
"engines": ["structure", "clamav", "document", "keywords", "yara", "binary"],
"engine_errors": [],
"clamav": { "version": "ClamAV 1.5.4", "signature_date": "2026-10-27" },
"policy_version": "2026.10.1",
"policy_sha256": "…",
"scanned_objects": 3,
"scanned_at": "2026-10-27T08:30:00+00:00"
}
}
```
**响应 200(异步受理)**
```json
{
"task_id": "3f8a9c2e1b7d4e6f8a0b2c4d6e8f0a1b",
"status": "pending",
"message": "任务已受理,请轮询 /api/v1/scans/{task_id} 获取结果"
}
```
**调用方处理建议**
```python
result = scan(file_bytes, filename)
if result["status"] != "completed":
result = poll_until_done(result["task_id"])
action = result["action"]
if action == "reject":
reject_transfer(reason=result["reason"]) # 拒绝并向用户展示 reason
elif action == "manual_review":
route_to_human_review(result["task_id"]) # 转人工
else:
allow_transfer() # pass / pass_with_flag
```
---
### 4.2 GET /api/v1/scans/{task_id}
查询扫描结果。同步模式下该接口用于重试补查,异步模式下用于轮询。
**路径参数**:`task_id` —— 提交扫描时返回的任务标识。
**响应 200**
结构与 `POST /api/v1/scans` 的同步响应一致。若任务尚未完成:
```json
{
"task_id": "…",
"status": "running",
"verdict": "pending",
"action": "pending",
"score": 0.0,
"reason": ""
}
```
`status` 取值:`pending` / `running` / `completed` / `failed`。
**响应 404**:任务不存在(同时排查 `client_id` 是否与提交时一致)。
---
### 4.3 POST /api/v1/scans/{task_id}/rescan
使用**当前生效策略**重新扫描同一文件。
**用途**:策略调整后,对历史文件重新判定;或人工复核阶段需要二次深检。
**响应 200**:同 4.1。
> 若原始文件已按留存策略清理,则返回 404。
---
### 4.4 POST /api/v1/scans/{task_id}/review
回写人工复核结论。
**请求体**
```json
{
"decision": "pass",
"comment": "经人工查验为已脱敏的内部文书,无风险"
}
```
| 字段 | 取值 | 说明 |
|---|---|---|
| `decision` | `pass` / `reject` | 复核结论 |
| `comment` | string | 复核说明,必填(审计要求) |
**响应 200**
```json
{ "task_id": "…", "decision": "pass", "recorded_at": "2026-10-27T09:00:00+00:00" }
```
**权限**:需 `operator` 角色。
---
### 4.5 GET /api/v1/health
健康检查与引擎状态。
**响应 200**
```json
{
"status": "healthy",
"policy_version": "2026.10.1",
"engines": {
"clamav": {
"available": true,
"version": "ClamAV 0.105.2/27012",
"signature": "27012",
"signature_updated_at": "2026-10-26T22:00:00+00:00"
},
"yara": { "available": true, "rules_loaded": true, "rules_dir": "/app/rules" },
"document": { "available": true },
"structure": { "available": true },
"binary": { "available": true }
},
"queue": { "pending": 0, "running": 0 },
"server_time": "2026-10-27T08:30:00+00:00"
}
```
**用途**:调用方启动时自检;运维监控告警(尤其 `clamav.available = false` 或签名库超过 7 天未更新)。
---
### 4.6 GET /api/v1/policies
查询当前生效的全局策略(只读)。
**响应 200**:返回完整策略 YAML 的 JSON 形式,含 `version`、`sha256`、`content`。
**权限**:`operator`。
---
### 4.7 POST /api/v1/policies/reload
从策略文件重新加载策略。
**用途**:修改策略文件后无需重启服务。
**响应 200**
```json
{ "reloaded": true, "version": "2026.10.2", "sha256": "…" }
```
**权限**:`operator`。
> 策略文件校验不通过时拒绝加载并保留原策略,响应 400 并说明校验错误原因。
---
### 4.8 GET /api/v1/audit/verify
校验审计日志哈希链完整性。
**响应 200**
```json
{
"checked": 1000,
"intact": true,
"broken_at_id": null,
"verified_at": "2026-10-27T08:30:00+00:00"
}
```
**权限**:`operator`。
---
## 5. 数据模型
### 5.1 结论字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `task_id` | string | 任务标识,全局唯一 |
| `verdict` | enum | 总体判定,见 §5.2 |
| `score` | float | 风险评分,0~100 |
| `action` | enum | 处置建议,见 §5.3 |
| `reason` | string | 人可读的判定理由,可直接展示给用户 |
| `status` | enum | `pending` / `running` / `completed` / `failed` |
### 5.2 verdict 取值
| 值 | 含义 |
|---|---|
| `clean` | 未发现风险 |
| `suspicious` | 存在可疑信号,需要关注或人工判断 |
| `malicious` | 判定为恶意 |
| `error` | 检测过程异常(如引擎不可用),结论不可信 |
| `pending` | 尚未完成 |
### 5.3 action 取值
| 值 | 含义 | 建议调用方动作 |
|---|---|---|
| `pass` | 未发现问题 | 正常放行 |
| `pass_with_flag` | 存在低风险信号 | 放行,但建议记录标记 |
| `manual_review` | 需人工判断 | **暂不放行**,转入人工复核流程 |
| `reject` | 判定不可放行 | 拒绝传输,向用户展示 `reason` |
| `pending` | 尚未裁决 | 等待异步结果 |
> **`action` 与 `verdict` 解耦使用。** 业务逻辑请依据 `action`,`verdict` 供人查看与统计。
### 5.4 findings 结构
| 字段 | 类型 | 说明 |
|---|---|---|
| `engine` | string | 引擎名:`structure` / `extractor` / `clamav` / `document` / `yara` / `binary` |
| `layer` | string | 检测层:`L0` / `L1` / `L2` / `L2B` / `L3` |
| `severity` | enum | `info` / `low` / `medium` / `high` / `critical` |
| `rule` | string | 规则标识 |
| `detail` | string | 命中详情 |
| `extracted_path` | string | 信号来自哪个内部路径(顶层文件为空) |
| `weight` | float | 该信号的风险权重 |
| `meta` | object | 引擎附加信息 |
### 5.5 严重度与权重对照
| 严重度 | 典型含义 | 权重区间 |
|---|---|---|
| `critical` | 确凿的恶意行为,自动执行、外联、路径穿越 | 50 ~ 90 |
| `high` | 高度可疑,宏调用、危险 API、高熵加壳 | 30 ~ 80 |
| `medium` | 可疑但存在合理场景,外部链接、可疑导入 | 15 ~ 45 |
| `low` | 轻微信号,仅编码字符串、无签名 | 5 ~ 20 |
| `info` | 信息性提示 | 0 |
### 5.6 file 结构
| 字段 | 说明 |
|---|---|
| `sha256` / `md5` | 文件哈希,用于去重与追溯 |
| `size` | 字节数 |
| `real_type` | 内容判定的真实类型描述 |
| `real_ext` | 真实扩展名(由内容推导) |
| `declared_ext` | 文件名声明的扩展名 |
| `filename` | 原始文件名 |
### 5.7 meta 结构
| 字段 | 说明 |
|---|---|
| `scan_duration_ms` | 检测耗时(毫秒) |
| `engines` | 参与的引擎列表 |
| `engine_errors` | 不可用的引擎列表,非空说明结论可能不完整 |
| `clamav` | ClamAV 版本与签名库编号 |
| `policy_version` | 判定所依据的策略版本 |
| `policy_sha256` | 策略文件哈希 |
| `scanned_objects` | 实际检测的对象数(含递归解包出的子文件) |
| `scanned_at` | 检测时间(UTC) |
> **`meta.clamav` 与 `meta.policy_version` 建议留存**,用于检测结果的有效性举证。
---
## 6. 检测规则清单
以下规则命中时会出现在 `findings` 中。**规则清单公开用于解释结论,不含检测规则的具体实现内容。**
### 6.1 L0 结构层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `ExtensionMismatch` | high | 扩展名声明与真实内容类型不符 |
| `DoubleExtension` | high | 文件名含多个扩展名,疑似伪装 |
| `DangerousExecutableExtension` | critical / high | 文件类型为可执行或脚本 |
| `ArchiveContainsExecutable` | high | 归档内含可执行文件 |
| `ArchiveBombSuspect` | high | 压缩比异常,疑似压缩炸弹 |
| `ArchiveTooLarge` | high | 解压后体积超限 |
| `ArchiveTooManyMembers` | medium | 归档成员数量异常 |
| `PathTraversal` | critical | 归档内含路径穿越条目 |
| `PathTraversalBlocked` | critical | 已拦截路径穿越条目 |
| `ArchiveBombBlocked` | high | 已拦截高压缩比成员 |
| `ArchiveUnreadable` | medium | 归档结构损坏 |
| `ExtractDepthExceeded` | medium | 嵌套层级超限 |
| `ExtractBudgetExceeded` | medium | 解包数量或体积超限 |
| `MemberTooLarge` | medium | 成员大小超限已跳过 |
### 6.2 L1 ClamAV 层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `ClamAV.<签名名>` | critical / high / medium | 命中病毒库签名。签名名随病毒库更新而变化 |
### 6.3 L2 文档深度层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `VBAAutoExecShell` | critical | 宏含自动执行入口并调用系统命令或外联 |
| `VBAShellInvocation` | high | 宏调用 Shell / CreateObject 执行外部命令 |
| `VBAAutoExec` | high | 宏含自动执行入口 |
| `VBAExternalLink` | medium | 宏中含外部链接 |
| `VBAEncodedString` | medium | 宏中含疑似编码混淆长串 |
| `VBAMacroPresent` | low | 文档含宏,需人工确认用途 |
| `MRaptorAutoExecSuspicious` | critical | 宏具备自动执行且含可疑行为 |
| `MRaptorAutoExec` | high | 宏含自动执行入口 |
| `DDEAutoExecution` | critical | 含 DDE / DDEAUTO 自动执行字段 |
| `EmbeddedExecutable` | critical | 文档内嵌可执行对象 |
| `EmbeddedOleObject` | medium | 文档内嵌 OLE 对象 |
| `ExternalLinkReference` | high | 工作簿含外部链接部件 |
| `ExternalUri` | high | 文档含指向外部地址的链接 |
| `AttachedTemplate` | high | 关联外部模板,可能触发模板注入 |
| `DocVars` | medium | 含文档变量,可能用于隐藏载荷 |
| `EmbeddedObject` | medium | 文档内嵌对象 |
| `PdfJavaScript` | critical | PDF 含 JavaScript 动作 |
| `PdfLaunchAction` | critical | PDF 含启动外部程序动作 |
| `PdfOpenAction` | high | PDF 定义了打开时自动执行的动作 |
| `PdfExternalUri` | medium | PDF 含外部 URI |
| `PdfEmbeddedFile` | high | PDF 内嵌文件 |
| `PdfEncrypted` | medium | PDF 已加密,无法完整静态分析 |
| `SuspiciousScript` | critical / high | 脚本含下载器、编码执行、关闭防护等可疑行为 |
### 6.4 L2B YARA 与二进制层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `<YARA 规则名>` | critical ~ low | 命中 YARA 规则 |
| `HighEntropySection` | medium | 节区熵值过高,可能加密或加壳 |
| `SuspiciousImports` | high | 导入表含高危 API |
| `UnsignedExecutable` | medium | 可执行文件未携带数字签名 |
| `SuspiciousTimestamp` | low | PE 编译时间戳异常 |
| `PeParseFailed` | medium | PE 结构解析失败 |
---
## 7. 错误处理
### 7.1 HTTP 状态码
| 状态码 | 含义 | 调用方应对 |
|---|---|---|
| 200 | 成功 | 正常处理结论 |
| 400 | 请求参数错误 / 策略校验失败 | 检查请求,不要盲目重试 |
| 401 | 认证失败 | 检查签名实现与时间戳同步 |
| 403 | 权限不足 | 检查凭证角色 |
| 404 | 资源不存在 | 检查 task_id |
| 413 | 文件超限 | 缩小文件或走异步流程 |
| 429 | 触发限流 | 退避后重试,建议指数退避 |
| 500 | 服务内部错误 | 重试;持续失败则告警 |
| 503 | 服务不可用(引擎未就绪) | 退避重试 |
### 7.2 错误响应格式
```json
{
"detail": "认证失败"
}
```
### 7.3 重试策略建议
| 状态码 | 是否重试 | 建议 |
|---|---|---|
| 400 / 401 / 403 / 404 | 否 | 属调用方问题,重试无意义 |
| 413 | 否 | 需改变请求内容 |
| 429 | 是 | 退避 1s → 2s → 4s,最大 3 次 |
| 500 / 503 | 是 | 退避重试,最大 3 次;持续失败转人工 |
> 网络超时导致的失败重试时,**必须更换 nonce 与时间戳**(见 §3.6)。
---
## 8. 接入示例
### 8.1 Python 完整实现
```python
import hashlib
import hmac
import json
import os
import time
import uuid
import httpx
EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes:
"""HKDF-SHA256。RFC 5869,仅需 extract + expand 两步。"""
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
okm = b""
block = b""
counter = 1
while len(okm) < length:
block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest()
okm += block
counter += 1
return okm[:length]
def derive_key(secret: str, client_id: str, purpose: str) -> bytes:
return hkdf_sha256(
ikm=secret.encode("utf-8"),
salt=client_id.encode("utf-8"),
info=purpose.encode("utf-8"),
length=32,
)
def canonical_query(params: dict) -> str:
if not params:
return ""
return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
def build_multipart(file_bytes: bytes, filename: str, field_name: str = "file",
extra: dict | None = None) -> tuple[bytes, str]:
"""手工构造 multipart 请求体,返回 (body_bytes, content_type)。
必须手工构造,因为签名需要基于最终字节计算 body 哈希。
"""
boundary = "----MeshSafeBoundary" + uuid.uuid4().hex
parts: list[bytes] = []
for key, value in (extra or {}).items():
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
f"{value}\r\n".encode("utf-8")
)
safe_name = filename.replace('"', "")
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n".encode("utf-8")
)
parts.append(file_bytes)
parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8"))
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
def sign_headers(secret: str, client_id: str, method: str, path: str,
query: dict | None, body: bytes) -> dict:
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
body_hash = hashlib.sha256(body).hexdigest()
qs = canonical_query(query or {})
payload = "\n".join([
method.upper(), path, qs, body_hash, ts, nonce, client_id,
])
key = derive_key(secret, client_id, "meshsafe/api/v1/request-signing")
signature = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return {
"X-MS-Client-Id": client_id,
"X-MS-Timestamp": ts,
"X-MS-Nonce": nonce,
"X-MS-Body-SHA256": body_hash,
"X-MS-Signature": signature,
}
def verify_response(secret: str, client_id: str, body: bytes,
timestamp: str, signature: str) -> bool:
"""校验响应签名。返回 False 表示结论不可信,必须拒绝使用。"""
key = derive_key(secret, client_id, "meshsafe/api/v1/response-signing")
payload = hashlib.sha256(body).hexdigest() + timestamp
expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
class MeshSafeClient:
def __init__(self, base_url: str, client_id: str, secret: str,
verify_tls: bool = True, timeout: float = 120.0):
self.base_url = base_url.rstrip("/")
self.client_id = client_id
self.secret = secret
self._client = httpx.Client(verify=verify_tls, timeout=timeout)
def scan(self, file_bytes: bytes, filename: str,
channel: str = "default") -> dict:
body, content_type = build_multipart(
file_bytes, filename, extra={"channel": channel}
)
path = "/api/v1/scans"
headers = sign_headers(
self.secret, self.client_id, "POST", path, None, body
)
headers["Content-Type"] = content_type
resp = self._client.post(
self.base_url + path, content=body, headers=headers
)
# 响应验签:不可省略
resp_ts = resp.headers.get("X-MS-Response-Timestamp", "")
resp_sig = resp.headers.get("X-MS-Response-Signature", "")
if not resp_ts or not resp_sig:
raise RuntimeError("响应缺少签名头,拒绝使用该结论")
if not verify_response(
self.secret, self.client_id, resp.content, resp_ts, resp_sig
):
raise RuntimeError("响应签名校验失败,拒绝使用该结论")
resp.raise_for_status()
return resp.json()
def poll(self, task_id: str, interval: float = 2.0, timeout: float = 600.0) -> dict:
path = f"/api/v1/scans/{task_id}"
deadline = time.time() + timeout
while time.time() < deadline:
headers = sign_headers(
self.secret, self.client_id, "GET", path, None, b""
)
resp = self._client.get(self.base_url + path, headers=headers)
resp.raise_for_status()
data = resp.json()
if data.get("status") in {"completed", "failed"}:
return data
time.sleep(interval)
raise TimeoutError(f"任务 {task_id} 超时")
# 使用示例
if __name__ == "__main__":
client = MeshSafeClient(
base_url="https://meshsafe.internal",
client_id="duandao-system-a",
secret="<你的 secret>",
)
with open("起诉状.docx", "rb") as fh:
data = fh.read()
result = client.scan(data, "起诉状.docx", channel="court_docs")
if result.get("status") != "completed":
result = client.poll(result["task_id"])
print(result["action"], result["reason"])
```
### 8.2 Java 示例
```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;
import java.util.UUID;
public class MeshSafeClient {
private static byte[] hkdfSha256(byte[] ikm, byte[] salt, byte[] info, int length) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
byte[] okm = new byte[length];
byte[] block = new byte[0];
int counter = 1;
int offset = 0;
while (offset < length) {
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(block);
mac.update(info);
mac.update(new byte[]{(byte) counter});
block = mac.doFinal();
int n = Math.min(block.length, length - offset);
System.arraycopy(block, 0, okm, offset, n);
offset += n;
counter++;
}
return okm;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static byte[] deriveKey(String secret, String clientId, String purpose) {
return hkdfSha256(
secret.getBytes(StandardCharsets.UTF_8),
clientId.getBytes(StandardCharsets.UTF_8),
purpose.getBytes(StandardCharsets.UTF_8),
32);
}
private static String sha256Hex(byte[] data) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return hex(md.digest(data));
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
private static String hmacHex(byte[] key, String message) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return hex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));
}
static Map<String, String> signHeaders(
String secret, String clientId, String method, String path,
String query, byte[] body) throws Exception {
String ts = String.valueOf(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString().replace("-", "");
String bodyHash = sha256Hex(body);
String payload = String.join("\n",
method.toUpperCase(), path, query == null ? "" : query,
bodyHash, ts, nonce, clientId);
byte[] key = deriveKey(secret, clientId, "meshsafe/api/v1/request-signing");
String signature = hmacHex(key, payload);
Map<String, String> headers = new HashMap<>();
headers.put("X-MS-Client-Id", clientId);
headers.put("X-MS-Timestamp", ts);
headers.put("X-MS-Nonce", nonce);
headers.put("X-MS-Body-SHA256", bodyHash);
headers.put("X-MS-Signature", signature);
return headers;
}
public static void main(String[] args) throws Exception {
String baseUrl = "https://meshsafe.internal";
String clientId = "duandao-system-a";
String secret = "<你的 secret>";
byte[] fileBytes = java.nio.file.Files.readAllBytes(
java.nio.file.Path.of("起诉状.docx"));
// 手工构造 multipart
String boundary = "----MeshSafeBoundary" + UUID.randomUUID().toString().replace("-", "");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
bos.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"doc.docx\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8));
bos.write(fileBytes);
bos.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
byte[] body = bos.toByteArray();
Map<String, String> headers = signHeaders(
secret, clientId, "POST", "/api/v1/scans", "", body);
headers.put("Content-Type", "multipart/form-data; boundary=" + boundary);
HttpClient http = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/v1/scans"))
.header("Content-Type", headers.get("Content-Type"))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
headers.forEach((k, v) -> { if (!k.equals("Content-Type")) req.headers().set(k, v); });
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
System.out.println(new String(resp.body(), StandardCharsets.UTF_8));
}
}
```
### 8.3 C# 示例
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
public class MeshSafeClient
{
private readonly string _baseUrl;
private readonly string _clientId;
private readonly string _secret;
private readonly HttpClient _http;
public MeshSafeClient(string baseUrl, string clientId, string secret)
{
_baseUrl = baseUrl.TrimEnd('/');
_clientId = clientId;
_secret = secret;
_http = new HttpClient();
}
private static byte[] HkdfSha256(byte[] ikm, byte[] salt, byte[] info, int length)
{
using var hmac = new HMACSHA256(salt);
byte[] prk = hmac.ComputeHash(ikm);
using var expand = new HMACSHA256(prk);
var okm = new byte[length];
byte[] block = Array.Empty<byte>();
int counter = 1, offset = 0;
while (offset < length)
{
var input = block.Concat(info).Concat(new[] { (byte)counter }).ToArray();
block = expand.ComputeHash(input);
int n = Math.Min(block.Length, length - offset);
Array.Copy(block, 0, okm, offset, n);
offset += n;
counter++;
}
return okm;
}
private byte[] DeriveKey(string purpose) =>
HkdfSha256(
Encoding.UTF8.GetBytes(_secret),
Encoding.UTF8.GetBytes(_clientId),
Encoding.UTF8.GetBytes(purpose),
32);
private static string Sha256Hex(byte[] data) =>
Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
private static string HmacHex(byte[] key, string message)
{
using var hmac = new HMACSHA256(key);
return Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant();
}
private Dictionary<string, string> SignHeaders(
string method, string path, string query, byte[] body)
{
string ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string nonce = Guid.NewGuid().ToString("N");
string bodyHash = Sha256Hex(body);
string qs = query ?? "";
string payload = string.Join("\n",
method.ToUpper(), path, qs, bodyHash, ts, nonce, _clientId);
string signature = HmacHex(
DeriveKey("meshsafe/api/v1/request-signing"), payload);
return new Dictionary<string, string>
{
["X-MS-Client-Id"] = _clientId,
["X-MS-Timestamp"] = ts,
["X-MS-Nonce"] = nonce,
["X-MS-Body-SHA256"] = bodyHash,
["X-MS-Signature"] = signature,
};
}
public async Task<string> ScanAsync(byte[] fileBytes, string filename)
{
string boundary = "----MeshSafeBoundary" + Guid.NewGuid().ToString("N");
using var ms = new MemoryStream();
var head = Encoding.UTF8.GetBytes(
$"--{boundary}\r\n" +
$"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n" +
"Content-Type: application/octet-stream\r\n\r\n");
ms.Write(head);
ms.Write(fileBytes);
ms.Write(Encoding.UTF8.GetBytes($"\r\n--{boundary}--\r\n"));
byte[] body = ms.ToArray();
var headers = SignHeaders("POST", "/api/v1/scans", "", body);
using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/v1/scans");
req.Content = new ByteArrayContent(body);
req.Content.Headers.TryAddWithoutValidation(
"Content-Type", $"multipart/form-data; boundary={boundary}");
foreach (var kv in headers)
req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync();
}
}
```
### 8.4 Go 示例
```go
package meshsafe
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/textproto"
"sort"
"strings"
"time"
)
// hkdfSHA256 实现 RFC 5869 的 HKDF-SHA256。
func hkdfSHA256(ikm, salt, info []byte, length int) []byte {
extractor := hmac.New(sha256.New, salt)
extractor.Write(ikm)
prk := extractor.Sum(nil)
var okm []byte
var block []byte
for counter := byte(1); len(okm) < length; counter++ {
h := hmac.New(sha256.New, prk)
h.Write(block)
h.Write(info)
h.Write([]byte{counter})
block = h.Sum(nil)
okm = append(okm, block...)
}
return okm[:length]
}
func deriveKey(secret, clientID, purpose string) []byte {
return hkdfSHA256(
[]byte(secret),
[]byte(clientID),
[]byte(purpose),
32,
)
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func hmacHex(key []byte, message string) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(message))
return hex.EncodeToString(mac.Sum(nil))
}
// SignHeaders 生成请求签名头。
func SignHeaders(secret, clientID, method, path, query string, body []byte) map[string]string {
ts := fmt.Sprintf("%d", time.Now().Unix())
nonce := fmt.Sprintf("%032x", time.Now().UnixNano())
bodyHash := sha256Hex(body)
payload := strings.Join([]string{
strings.ToUpper(method), path, query, bodyHash, ts, nonce, clientID,
}, "\n")
signature := hmacHex(
deriveKey(secret, clientID, "meshsafe/api/v1/request-signing"),
payload,
)
return map[string]string{
"X-MS-Client-Id": clientID,
"X-MS-Timestamp": ts,
"X-MS-Nonce": nonce,
"X-MS-Body-SHA256": bodyHash,
"X-MS-Signature": signature,
}
}
// Scan 提交文件扫描。
func Scan(baseURL, clientID, secret string, fileName string, fileBytes []byte) ([]byte, error) {
var buf bytes.Buffer
boundary := "----MeshSafeBoundary" + fmt.Sprintf("%032x", time.Now().UnixNano())
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n")
buf.WriteString("Content-Type: application/octet-stream\r\n\r\n")
buf.Write(fileBytes)
buf.WriteString("\r\n--" + boundary + "--\r\n")
body := buf.Bytes()
path := "/api/v1/scans"
headers := SignHeaders(secret, clientID, "POST", path, "", body)
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// 响应验签
if ts := resp.Header.Get("X-MS-Response-Timestamp"); ts != "" {
sig := resp.Header.Get("X-MS-Response-Signature")
expected := hmacHex(
deriveKey(secret, clientID, "meshsafe/api/v1/response-signing"),
sha256Hex(respBody)+ts,
)
if !hmac.Equal([]byte(expected), []byte(sig)) {
return nil, fmt.Errorf("响应签名校验失败")
}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("接口返回 %d: %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
// canonicalQuery 按 key 升序生成查询串,供 GET 请求签名使用。
func canonicalQuery(params map[string]string) string {
if len(params) == 0 {
return ""
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
return strings.Join(parts, "&")
}
var _ = textproto.MIMEHeader{}
```
---
## 9. 附录
### 9.1 快速排查清单
对接出现问题时,按此顺序检查:
| # | 检查项 | 常见错误 |
|---|---|---|
| 1 | 系统时钟同步 | 服务器与调用方时钟偏差超过 300 秒 |
| 2 | 签名密钥派生 | 直接用 `secret` 做 HMAC 密钥,未做 HKDF 派生 |
| 3 | 待签名串顺序 | 各部分顺序或分隔符不符 |
| 4 | 换行符 | 使用了 `\r\n` 而非 `\n` |
| 5 | 十六进制大小写 | 输出了大写 hex |
| 6 | body 哈希 | 用 `files=` 自动构造 multipart,未基于最终字节计算 |
| 7 | nonce 复用 | 重试时沿用了同一个 nonce |
| 8 | PATH 取值 | 包含了域名或查询串 |
| 9 | 响应验签 | 验签时用了请求签名密钥而非响应签名密钥 |
| 10 | 密钥编码 | secret 前后有多余空格 |
### 9.2 术语
| 术语 | 说明 |
|---|---|
| 摆渡 | 通过光闸在内外网之间传输文件 |
| 结论文本 | 一次检测的最终判定结果 |
| findings | 一次检测中命中的全部信号明细 |
| verdict | 总体判定(clean / suspicious / malicious / error) |
| action | 处置建议(pass / pass_with_flag / manual_review / reject) |
| 幂等 | 同一文件哈希与同一策略版本只检测一次,重复请求直接复用结果 |
### 9.3 联系方式
| 事项 | 联系 |
|---|---|
| 凭证申请与轮换 | 系统管理员 |
| 策略调整 | 安全管理员 |
| 接口异常与故障 | 运维值班 |
---
**文档结束** · 配套文档:《策略配置说明》《等保三级对照表》《运维手册》