核心功能: - 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
1323 lines
41 KiB
Markdown
1323 lines
41 KiB
Markdown
# 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 联系方式
|
||
|
||
| 事项 | 联系 |
|
||
|---|---|
|
||
| 凭证申请与轮换 | 系统管理员 |
|
||
| 策略调整 | 安全管理员 |
|
||
| 接口异常与故障 | 运维值班 |
|
||
|
||
---
|
||
|
||
**文档结束** · 配套文档:《策略配置说明》《等保三级对照表》《运维手册》
|