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
View File
+156
View File
@@ -0,0 +1,156 @@
"""与服务商无关的生成领域模型。
**本模块不得 import 任何服务商代码,也不得 import httpx。** 这不是洁癖,是本层
存在的唯一理由——Java 侧、未来的 gpt-image-2、以及任何第三家供应商都对着这一层
编程。一旦这里漏进一个厂商概念,「换供应商不动上层」就不再成立,而那正是分层
想买到的东西。
判断某个概念该放这里还是放 `providers/<厂商>/`:问「换一家供应商,这个概念还在
吗」。`Retry`/`UserFacing` 描述的是 **Worker 与客户端该怎么做**,与谁家供应商
无关,故在此;`expired` 状态、`QuotaExceeded` 这类码是火山特有的,故不在此。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class MediaKind(Enum):
"""生成产物的类型,与 `creation.generation_jobs.media_kind` 同值。"""
IMAGE = "image"
VIDEO = "video"
class Retry(Enum):
"""失败后该怎么办。由服务商适配层判定,由 Java 侧的 Worker 执行。"""
#: 不可重试:同样的输入必然得到同样的结果(参数错、内容被拒、鉴权失败)。
NEVER = "never"
#: 可重试且应尽快:限流、突发流量保护、服务过载。退避几秒到几十秒。
BACKOFF = "backoff"
#: 可重试但要等很久:额度/配额耗尽,通常要等到下一个计费周期或充值之后。
#: Worker 不该把重试预算浪费在这上面,应直接终态失败并让用户看到出口。
QUOTA = "quota"
class UserFacing(Enum):
"""给终端用户的呈现类别,对应客户端的两个 429 分支(ADR-023 §9)。
「配额耗尽」与「频率过快」必须分开:前者等的是明天或充值(弹 sheet 给出口),
后者等的是几秒(弹 SnackBar 可自动重试)。合成一类必然有一半场景的文案和
出口是错的。
"""
QUOTA_EXHAUSTED = "quota_exhausted"
RATE_LIMITED = "rate_limited"
CONTENT_REJECTED = "content_rejected"
SERVER_ERROR = "server_error"
UPSTREAM_UNAVAILABLE = "upstream_unavailable"
@dataclass(frozen=True)
class Failure:
"""一次失败的完整描述。
:param provider_code: 原始厂商错误码,**原样保留、绝不丢弃**——排查时它往往
是唯一线索。领域层不理解它的含义,只负责把它带到日志和响应里。
"""
retry: Retry
user_facing: UserFacing
provider_code: str
message: str = ""
class TaskState(Enum):
"""与服务商无关的任务状态。
刻意比火山的状态集**更小**:火山有 queued / running / cancelled / succeeded /
failed / expired 六个,这里把 queued 与 running 合成 PENDING/RUNNING 两态、
把 expired 归入 FAILED(它是超时,属于失败的一种,重试策略由 `Failure`
单独表达)。映射的取舍放在适配层,领域层只保留上层真正需要区分的状态。
"""
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
@property
def is_terminal(self) -> bool:
return self in {TaskState.SUCCEEDED, TaskState.FAILED, TaskState.CANCELLED}
@dataclass(frozen=True)
class GenerationRequest:
"""一次生成请求,服务商无关。
这是 Java 侧发过来的内容。**刻意不含 `user_id`**:本服务干活只需要
`job_id`、生成参数与上传目标,传用户标识属于不必要的扩散(ADR-023 §5
数据最小化)。需要给厂商做滥用检测时传的是 `safety_identifier`——一个哈希,
不是明文用户 ID。
:param model: 厂商侧的模型标识,由 Java 侧从目录表的 `provider_model_name`
快照取值后传入。**适配层不自行选模型**——选型是目录的职责,且任务行里
冻结的快照必须与实际调用的模型一致。
:param input_url: 参考图/首帧的公网可达 URL。为空即文生视频(参考图选填,
决策 A2)。
:param upload_target: 产物的预签名 PUT 地址。当前未启用(转存阻塞于存储
决策,ADR-023 §7),保留字段使将来启用无需改契约。
"""
job_id: str
media_kind: MediaKind
model: str
prompt: str | None = None
input_url: str | None = None
ratio: str | None = None
resolution: str | None = None
duration_sec: int | None = None
generate_audio: bool = True
watermark: bool = False
safety_identifier: str | None = None
callback_url: str | None = None
upload_target: str | None = None
@dataclass(frozen=True)
class GenerationResult:
"""一次查询的结果,服务商无关。
:param asset_url: 产物 URL。**可能是有时效的厂商地址**——火山 Seedance 的
有效期是 24 小时,因此调用方必须在有效期内转存,不可长期保存此值。
适配层通过 `asset_url_expires_in_sec` 告知时效,避免上层把它当永久地址。
:param cover_url: 封面图 URL(视频场景)。火山的尾帧图可免费充当此用途。
:param width_px: 像素宽高。**火山不直接返回像素值**,只返回 `ratio` 与
`resolution`;换算需要一张随模型family变化的对照表。故此处在 V7 目录
种子定下模型族之前保持 None,并把 `ratio`/`resolution` 原样透出,
由上层或后续实现换算——编一个可能错的尺寸比留空更糟。
:param billing_tokens: 计费对账依据(火山为 `usage.completion_tokens`)。
"""
provider_request_id: str
state: TaskState
progress: int = 0
asset_url: str | None = None
asset_url_expires_in_sec: int | None = None
cover_url: str | None = None
ratio: str | None = None
resolution: str | None = None
width_px: int | None = None
height_px: int | None = None
duration_ms: int | None = None
output_format: str | None = None
failure: Failure | None = None
billing_tokens: int | None = None
def __post_init__(self) -> None:
# 失败态必须带 Failure,否则上层无法决定重试策略,只能盲目重试或盲目
# 放弃——两者都错。宁可在这里炸掉也不要把半个失败态放出去。
if self.state is TaskState.FAILED and self.failure is None:
raise ValueError("FAILED 状态必须携带 Failure")
if self.state is TaskState.SUCCEEDED and not self.asset_url:
raise ValueError("SUCCEEDED 状态必须携带 asset_url")