feat(chv): 对齐 Muse P10 支付安全——凭证隔离 + 结账检测 + 每次购买人工审批 + 一次性卡号

- 支付凭证隔离:PaymentCredentialVault(只活内存、白名单
  stripe-link/shop-pay、只存不透明 id + 掩码标签、原始卡号
  PAN 拒绝入库、按次释放绑定商家/金额/货币、单次 + TTL)
- 结账页检测:detect_checkout(URL + 无障碍树信号,确定性
  规则、不读原始 DOM、与 P4 detect_form_risk 同输入形状)
- 每次购买人工审批:PaymentGate 经 P5 对话外通道发审批,
  payload 含确切购买详情;完成时先验签、再验详情摘要
  (篡改即拒且审批保持待处理)、最后单次消费答复
- 一次性卡号:OneTimeCard 绑定商家/金额/货币/有效期,单次
  兑现;SimulatedWalletBackend 沙箱钱包(假 PAN 只在后端
  私有内存、charge 只记账不联网、取消即 void)
- 限额:SpendLimits 三档 minor units 比对,限额不可用时
  不编造上限(limits_checked=False)
- 审计:PaymentAudit 只留允许字段,秘密字段名丢弃 + redacted
- 审批通道最小扩展:ApprovalRequest 增加可选 details 字段,
  不传时 payload 与 P5 完全一致
- 真实支付与扣款不做:全部沙箱验证,真实钱包集成待用户
  另行批准;新测试 79 项,全量 cloud 套件 2037 OK

round-12 集成说明:本提交为 acf98f8 的 cherry-pick(只含 P10
自有 diff,基线 25bcd35 已含的 P3/P5 内容未重复合入;
approval_channel.py 仅加可选 details 字段)。
This commit is contained in:
host committed 2026-11-07 00:09:55 +00:00
1 parent 90c42f6e1d
commit b55b49b358
7 files changed
+1939 -8

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
"""结账页检测(feat-chv-muse-p10,叶子模块,纯标准库)。
对齐 Muse 官方安全文(security.muse.ai 2026-09-08,见
docs/chv-muse-p10-gap.md):已保存支付凭证的网站,浏览器内置
结账页检测,每次都弹出人工审批并展示确切购买详情。
本模块是纯函数,不启动浏览器、不联网、不读原始 DOM:
- 输入只有 URL + 无障碍树节点(与 P4 的 ``detect_form_risk``
同输入形状——P2 的浏览器子代理只返回无障碍树快照,节点
只有 role/name/label/type 等描述字段);
- 输出 ``CheckoutVerdict``:是否结账页、置信度、命中原因、
表单风险、页面金额文本;
- 确定性规则,不冒充模型分类器(见 P6 口径)。
诚实边界:启发式检测,不是 100% 检出——商户自定义结账页、
单页应用内嵌结账可能漏检。漏检时的安全底线在调用方
(``PaymentGate`` 拒绝任何未经审批的扣款),不在本模块
宣称“所有结账页都会被拦下”。
页面金额文本(``amount_text``)只作展示信号:扣款金额永远
以 ``PurchaseDetails``(订单数据)为准,页面文本是不受信任
输入(P6 口径),不得直接拿去扣款。
"""
import re
from dataclasses import dataclass, field
from urllib.parse import urlparse
# URL 路径段信号:结账步骤(高)与购物车(中,结账前一步)。
_CHECKOUT_SEGMENTS = ("checkout", "check-out", "payment", "pay",
"billing", "order-review", "place-order",
"confirm-order")
_CART_SEGMENTS = ("cart", "basket", "bag")
# 无障碍树节点文本信号(小写子串匹配)。
_PAYMENT_HINTS = ("card number", "credit card", "debit card",
"cardholder", "expiry", "expiration", "exp date",
"cvc", "cvv", "security code", "卡号", "信用卡",
"有效期", "安全码")
_ORDER_HINTS = ("place order", "pay now", "confirm purchase",
"complete purchase", "checkout", "下单", "立即支付",
"确认购买", "去支付", "提交订单")
_PASSWORD_HINTS = ("password", "密码")
_AMOUNT_RE = re.compile(
r"[$€£¥]\s?\d[\d,]*(?:\.\d{1,2})?"
r"|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:USD|EUR|GBP|JPY|CNY)\b")
@dataclass
class CheckoutVerdict:
"""一次结账检测的结果(只含描述,不含任何凭证值)。"""
is_checkout: bool
confidence: str # high / medium / low
reasons: list = field(default_factory=list)
form_risk: str | None = None # payment / password / None
amount_text: str | None = None
def _node_text(node: dict) -> str:
"""一个无障碍树节点的描述文本(role/name/label/type
拼起来,小写;原始 DOM 从不到这里)。"""
parts = [str(node.get(k, "")) for k in
("role", "name", "label", "type")]
return " ".join(parts).lower()
def _url_level(url: str) -> str:
"""URL 信号档位:high(结账路径)/ medium(购物车)/
none。只看 path,不看 query(query 里可能有订单号等
敏感值,不进原因)。"""
try:
path = urlparse(url).path.lower()
except ValueError:
return "none"
segments = [s for s in path.split("/") if s]
if any(s in _CHECKOUT_SEGMENTS for s in segments):
return "high"
if any(s in _CART_SEGMENTS for s in segments):
return "medium"
return "none"
def _node_signals(nodes: list) -> tuple:
"""无障碍树信号:(支付字段, 密码字段, 下单按钮, 金额文本)。
节点文本只作特征匹配,不存原文。"""
payment = False
password = False
order = False
amount: str | None = None
for node in nodes or []:
text = _node_text(node)
if not text.strip():
continue
if any(h in text for h in _PAYMENT_HINTS):
payment = True
if any(h in text for h in _PASSWORD_HINTS):
password = True
if any(h in text for h in _ORDER_HINTS):
order = True
if amount is None:
hit = _AMOUNT_RE.search(text)
if hit:
amount = hit.group(0)
return payment, password, order, amount
def detect_checkout(url: str, nodes: list | None = None
) -> CheckoutVerdict:
"""检测一个页面是否为结账页。
判定(确定性规则):支付字段在页 → 结账页(高置信);
结账路径 URL → 结账页(有下单按钮/金额则高,否则中);
下单按钮 + 金额文本 → 结账页(中置信);只命中购物车
路径 → 不是结账页(购物车是结账前一步);其余 → 不是。
``form_risk``:支付字段在页为 ``payment``,否则密码字
段在页为 ``password``,与 P4 的 ``detect_form_risk``
口径一致。"""
level = _url_level(url)
has_payment, has_password, has_order, amount = _node_signals(
nodes or [])
reasons: list = []
if level == "high":
reasons.append("checkout_url")
elif level == "medium":
reasons.append("cart_url")
if has_payment:
reasons.append("payment_fields")
if has_order:
reasons.append("order_button")
if amount is not None:
reasons.append("amount_text")
risk = "payment" if has_payment else (
"password" if has_password else None)
if has_payment:
return CheckoutVerdict(True, "high", reasons, risk, amount)
if level == "high":
conf = "high" if has_order or amount else "medium"
return CheckoutVerdict(True, conf, reasons, risk, amount)
if has_order and amount is not None:
return CheckoutVerdict(True, "medium", reasons, risk, amount)
return CheckoutVerdict(False, "low", reasons, risk, amount)