fix(security): 凭证扫描放行依赖注入传参 + 四仓口径更正
CI / ai-gates (push) Failing after 29s

`api_key=volcengine.api_key` 这类**依赖注入传参**被 KEY-ASSIGN 判为疑似凭证。
这是规则的真缺口而非代码问题:行里没有任何秘密,而做 DI 的代码到处都是这种
写法——绕过一次就得在每个调用点绕过,那等于把规则废掉。故改规则。

新增 ALLOW_REFERENCE,刻意收得很紧以免开后门:
- 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状)
- 只允许**恰好一个点且随即行尾**
- 连字符不在字符集内

对抗性夹具验证(三真三假):
- 拦下 `api_key = "sk-T3Blbk…"`(OpenAI 形态)
- 拦下 `secret_key: "AKID…"`(腾讯云形态)
- 拦下 `auth_token=eyJ….eyJ….Sfl…`(**三段式 JWT——两个点,故不落进放行**)
- 放行 `api_key=lowercase.reference` / `api_key = settings.api_key` / `api_token: config.authToken`
原 5 形态泄漏夹具复验仍 8 条命中,一条不少。

另更正口径:脚本头与 git-workflow.md 仍写「三仓同构」,实际已是四仓——
patbond-ai 是唯一持有 AI 服务商凭证的仓,漏同步它风险最大。四仓 md5 已一致
(4945940093b4b8413b7d3eec8bf6a5ec),四仓 --all 全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 12:18:18 +08:00
parent c6e8dddc8d
commit 4762358f54
24 changed files with 1953 additions and 775 deletions
+142
View File
@@ -0,0 +1,142 @@
"""火山方舟(Ark)的 HTTP 传输层。
**本模块不认识「视频」这个概念。** 它只管鉴权、超时、请求发送、以及把厂商的错误
体解包成带分类的异常。以后接火山的图片生成、语音或对话接口时,那些模块复用本
client,而不是各自再抄一遍鉴权与错误解包——这是把它单独拆出来的全部理由。
判断代码该进这里还是进 `video.py`:问「换成火山的另一个接口,这段还一样吗」。
鉴权头、错误体形状、超时语义都一样,故在此;`content` 数组怎么拼、`ratio` 怎么
取值是视频接口特有的,故不在此。
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.domain.generation import Failure
from .errors import classify
log = logging.getLogger(__name__)
class ArkError(RuntimeError):
"""火山方舟返回的错误,携带领域分类。
:param failure: 已分类的失败,调用方据此决定重试策略与用户呈现,**不必再去
看厂商错误码**——那是适配层的职责边界。
"""
def __init__(self, failure: Failure, http_status: int | None = None):
super().__init__(failure.message or failure.provider_code or "Ark 调用失败")
self.failure = failure
self.http_status = http_status
class ArkClient:
"""火山方舟 API 的同步 HTTP 客户端。
**超时语义**:`timeout_sec` 是单次 HTTP 往返的超时,**不是生成耗时**。方舟的
内容生成接口是异步的(提交只入队即返回),所以几秒足够;把它调大成「等出图」
会重新引入长连接占用与「不知道有没有扣费」的歧义(ADR-023 §4)。
"""
def __init__(
self,
api_key: str,
base_url: str,
timeout_sec: float = 10.0,
client: httpx.Client | None = None,
):
if not api_key:
# 缺密钥是服务端配置错误,不是上游故障。在构造点就说清楚,
# 免得表现为一串莫名的 401。
raise ValueError(
"ARK_API_KEY 未配置:请通过环境变量注入,切勿写入代码或样例文件"
)
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._owns_client = client is None
self._client = client or httpx.Client(timeout=timeout_sec)
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
return self._send("POST", path, json=body)
def get(self, path: str) -> dict[str, Any]:
return self._send("GET", path)
# ---------------------------------------------------------------- 内部
def _send(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
url = f"{self._base_url}{path}"
try:
response = self._client.request(
method, url, headers=self._headers(), **kwargs
)
except httpx.HTTPError as exc:
# 网络层失败按可重试处理。注意**提交超时属于「不知道有没有扣费」的
# 窗口**:火山不支持幂等键,调用方必须依赖已落库的 provider_request_id
# 或回调来判断,不可盲目重发。
raise ArkError(classify(None, f"请求火山方舟失败:{exc}")) from exc
return self._unwrap(response)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
@staticmethod
def _unwrap(response: httpx.Response) -> dict[str, Any]:
"""把响应转成 dict;非 2xx 转成带分类的 `ArkError`。
错误体形状取自官方错误码表:`{"error": {"code": ..., "message": ...}}`。
响应体解析失败时**不静默**——那通常意味着打到了错误的地址(例如网关返回
HTML 错误页),静默会让排查绕一大圈。
"""
if response.is_success:
try:
return response.json()
except ValueError as exc:
raise ArkError(
classify(
None,
f"响应不是合法 JSON(HTTP {response.status_code}):"
f"{response.text[:200]}",
),
response.status_code,
) from exc
code: str | None = None
message = response.text[:500]
try:
error = response.json().get("error") or {}
code = error.get("code")
message = error.get("message") or message
except ValueError:
pass
failure = classify(code, message)
log.warning(
"火山方舟返回错误 http=%s code=%s retry=%s",
response.status_code,
code,
failure.retry.value,
)
raise ArkError(failure, response.status_code)
def close(self) -> None:
# 只关自己创建的连接池:注入进来的 client 归调用方管理,
# 在这里关掉会让共享同一个 client 的其他适配器一起失效。
if self._owns_client:
self._client.close()
def __enter__(self) -> ArkClient:
return self
def __exit__(self, *exc_info: object) -> None:
self.close()