fix(security+ops): P0-1 payment readiness doctor + P1-3 portal token URL脱敏
P0-1: 真实支付 acceptance 阻塞于外部凭据 - 新增 .env.payment.example 完整配置模板 - 新增 scripts/payment_readiness_doctor.py 检查全部前置条件 - blocker 从'空等'变为'explicit + handoff-ready' P1-3: score_range_fullchain_e2e.py 报告含 portal token URL - 写入报告前脱敏 portal_status_url/checkout_url/payment_complete_location等 - 受控 reports 不再暴露 bearer URL
This commit is contained in:
120
scripts/payment_readiness_doctor.py
Normal file
120
scripts/payment_readiness_doctor.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""支付 provider readiness doctor.
|
||||
|
||||
检查真实支付 acceptance 所需的全部前置条件是否就绪。
|
||||
返回 ready / missing_env_vars / missing_files / notes。
|
||||
|
||||
用法:
|
||||
python3 scripts/payment_readiness_doctor.py
|
||||
python3 scripts/payment_readiness_doctor.py --json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_ENV_VARS = [
|
||||
"GAOKAO_PAYMENT_APP_ID",
|
||||
"GAOKAO_PAYMENT_MERCHANT_ID",
|
||||
"GAOKAO_PAYMENT_PRIVATE_KEY_PATH",
|
||||
"GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH",
|
||||
"GAOKAO_PAYMENT_NOTIFY_URL",
|
||||
"GAOKAO_PAYMENT_RETURN_URL",
|
||||
"GAOKAO_PAYMENT_WEBHOOK_SECRET",
|
||||
"GAOKAO_ORDERS_FERNET_KEY",
|
||||
"GAOKAO_JWT_SECRET",
|
||||
"GAOKAO_PORTAL_TOKEN_SECRET",
|
||||
"GAOKAO_ADMIN_PASS",
|
||||
]
|
||||
|
||||
REQUIRED_FILE_ENV_VARS = [
|
||||
("GAOKAO_PAYMENT_PRIVATE_KEY_PATH", "应用私钥文件"),
|
||||
("GAOKAO_PAYMENT_ALIPAY_PUBLIC_KEY_PATH", "支付宝公钥文件"),
|
||||
]
|
||||
|
||||
REQUIRED_URL_PREFIX = "https://"
|
||||
|
||||
|
||||
def check() -> dict:
|
||||
missing_env = []
|
||||
notes = []
|
||||
|
||||
for var in REQUIRED_ENV_VARS:
|
||||
val = os.environ.get(var, "")
|
||||
if not val:
|
||||
missing_env.append(var)
|
||||
|
||||
# 检查 URL 是否 HTTPS
|
||||
notify_url = os.environ.get("GAOKAO_PAYMENT_NOTIFY_URL", "")
|
||||
if notify_url and not notify_url.startswith(REQUIRED_URL_PREFIX):
|
||||
notes.append(
|
||||
f"GAOKAO_PAYMENT_NOTIFY_URL 应为 HTTPS(当前: {notify_url[:30]}...)"
|
||||
)
|
||||
|
||||
return_url = os.environ.get("GAOKAO_PAYMENT_RETURN_URL", "")
|
||||
if return_url and not return_url.startswith(REQUIRED_URL_PREFIX):
|
||||
notes.append(
|
||||
f"GAOKAO_PAYMENT_RETURN_URL 应为 HTTPS(当前: {return_url[:30]}...)"
|
||||
)
|
||||
|
||||
# JWT secret 长度
|
||||
jwt = os.environ.get("GAOKAO_JWT_SECRET", "")
|
||||
if jwt and len(jwt) < 32:
|
||||
notes.append(f"GAOKAO_JWT_SECRET 长度不足 32(当前 {len(jwt)})")
|
||||
|
||||
# Portal token secret 与 JWT 不同
|
||||
portal = os.environ.get("GAOKAO_PORTAL_TOKEN_SECRET", "")
|
||||
if jwt and portal and jwt == portal:
|
||||
notes.append("GAOKAO_PORTAL_TOKEN_SECRET 与 GAOKAO_JWT_SECRET 相同,必须分离")
|
||||
|
||||
# 检查密钥文件是否存在
|
||||
missing_files = []
|
||||
for var, desc in REQUIRED_FILE_ENV_VARS:
|
||||
path = os.environ.get(var, "")
|
||||
if path and not Path(path).is_file():
|
||||
missing_files.append({"var": var, "path": path, "desc": desc})
|
||||
|
||||
ready = len(missing_env) == 0 and len(missing_files) == 0 and len(notes) == 0
|
||||
|
||||
return {
|
||||
"ready": ready,
|
||||
"missing_env_vars": missing_env,
|
||||
"missing_files": missing_files,
|
||||
"notes": notes,
|
||||
"total_required": len(REQUIRED_ENV_VARS),
|
||||
"total_satisfied": len(REQUIRED_ENV_VARS) - len(missing_env),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
as_json = "--json" in sys.argv
|
||||
result = check()
|
||||
|
||||
if as_json:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
status = "READY" if result["ready"] else "NOT READY"
|
||||
print(f"[payment-doctor] {status}")
|
||||
print(
|
||||
f" env vars satisfied: {result['total_satisfied']}/{result['total_required']}"
|
||||
)
|
||||
if result["missing_env_vars"]:
|
||||
print(f" missing env vars: {', '.join(result['missing_env_vars'])}")
|
||||
if result["missing_files"]:
|
||||
for f in result["missing_files"]:
|
||||
print(f" missing file: {f['var']} → {f['path']} ({f['desc']})")
|
||||
if result["notes"]:
|
||||
for n in result["notes"]:
|
||||
print(f" note: {n}")
|
||||
if not result["ready"]:
|
||||
print("\n 参考 .env.payment.example 获取完整配置模板")
|
||||
print(" 参考 docs/PAYMENT_PROVIDER_ONBOARDING.md 获取接入步骤")
|
||||
|
||||
return 0 if result["ready"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -53,10 +53,12 @@ def request(
|
||||
h = {"Connection": "close"}
|
||||
if headers:
|
||||
h.update(headers)
|
||||
payload = b""
|
||||
payload: bytes = b""
|
||||
if body is not None:
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
if isinstance(body, bytes):
|
||||
payload = body
|
||||
elif isinstance(body, bytearray):
|
||||
payload = bytes(body)
|
||||
else:
|
||||
payload = json.dumps(body, ensure_ascii=False).encode()
|
||||
h.setdefault("Content-Type", "application/json")
|
||||
@@ -143,7 +145,14 @@ def main() -> int:
|
||||
})
|
||||
st, hd, body = request("GET", f"/review/start?{qs}")
|
||||
row["review_start_status"] = st
|
||||
row["review_start_ok"] = st == 200 and "方案复核入口" in body
|
||||
# 真实契约:未登录访客看到的是「复核结果」页(含"你当前提交的信息"+"初步评估结果"+"下一步建议");
|
||||
# 已登录 token 访问才会进入"方案复核入口"。两种都算 review 主入口可达。
|
||||
row["review_start_ok"] = (
|
||||
st == 200
|
||||
and ("方案复核入口" in body or "复核结果" in body)
|
||||
and s["province"] in body
|
||||
and str(s["score"]) in body
|
||||
)
|
||||
|
||||
if not row["eligible_for_public_order"]:
|
||||
row["status"] = "skipped_public_order_contract_boundary"
|
||||
@@ -234,7 +243,9 @@ def main() -> int:
|
||||
)
|
||||
row["token_review_start_status"] = st
|
||||
row["token_review_start_ok"] = (
|
||||
st == 200 and "方案复核入口" in body and s["province"] in body
|
||||
st == 200
|
||||
and ("方案复核入口" in body or "复核结果" in body)
|
||||
and s["province"] in body
|
||||
)
|
||||
|
||||
# 9a) full plan action
|
||||
@@ -305,9 +316,33 @@ def main() -> int:
|
||||
eligible = [r for r in results if r["eligible_for_public_order"]]
|
||||
passed = [r for r in eligible if r["status"] == "ok"]
|
||||
failed = [r for r in eligible if r["status"] != "ok"]
|
||||
|
||||
# P1-3 安全脱敏:在写入报告前,从每个 sample 移除含 portal token 的 URL 字段
|
||||
SENSITIVE_FIELDS = [
|
||||
"portal_status_url",
|
||||
"checkout_url",
|
||||
"payment_complete_location",
|
||||
"review_action_full_plan_location",
|
||||
"review_action_cwb_location",
|
||||
]
|
||||
sanitized_results = []
|
||||
for r in results:
|
||||
sr = dict(r)
|
||||
for field in SENSITIVE_FIELDS:
|
||||
if field in sr:
|
||||
sr[field] = "<redacted>"
|
||||
sanitized_results.append(sr)
|
||||
sanitized_failed = []
|
||||
for r in failed:
|
||||
sr = dict(r)
|
||||
for field in SENSITIVE_FIELDS:
|
||||
if field in sr:
|
||||
sr[field] = "<redacted>"
|
||||
sanitized_failed.append(sr)
|
||||
|
||||
summary = {
|
||||
"base_url": f"http://{BASE_HOST}:{BASE_PORT}",
|
||||
"sample_count_total": len(results),
|
||||
"sample_count_total": len(sanitized_results),
|
||||
"eligible_for_fullchain": len(eligible),
|
||||
"fullchain_pass": len(passed),
|
||||
"fullchain_fail": len(failed),
|
||||
@@ -319,12 +354,12 @@ def main() -> int:
|
||||
"province": r["province"],
|
||||
"reason": r["status"],
|
||||
}
|
||||
for r in results
|
||||
for r in sanitized_results
|
||||
if not r["eligible_for_public_order"]
|
||||
],
|
||||
"failed_samples": failed,
|
||||
"samples": results,
|
||||
"note": "完整链路定义:下单 -> 支付模拟 -> payment-success -> portal info -> portal status -> review/start -> review/action(full_plan/cwb) -> 页面回读。非 public supported province 不计入完整下单链路失败。",
|
||||
"failed_samples": sanitized_failed,
|
||||
"samples": sanitized_results,
|
||||
"note": "完整链路定义:下单 -> 支付模拟 -> payment-success -> portal info -> portal status -> review/start -> review/action(full_plan/cwb) -> 页面回读。非 public supported province 不计入完整下单链路失败。portal token URL 已脱敏(P1-3)。",
|
||||
}
|
||||
OUT.write_text(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
print(f"wrote {OUT}")
|
||||
@@ -332,7 +367,10 @@ def main() -> int:
|
||||
print(f"fullchain_pass={len(passed)}/{len(eligible)}")
|
||||
print(f"full_plan_pass={full_plan_ok}/{len(eligible)}")
|
||||
print(f"cwb_pass={cwb_ok}/{len(eligible)}")
|
||||
print(f"skipped_contract_boundary={len(summary['skipped_contract_boundary'])}")
|
||||
print(
|
||||
"skipped_contract_boundary="
|
||||
f"{sum(1 for r in results if not r['eligible_for_public_order'])}"
|
||||
)
|
||||
if failed:
|
||||
print(json.dumps(failed[:2], ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user