Files
host 7091a2d0d2 P0 security batch: true global admin, quota gates on all spending paths, default quota for quota-less users, fix-review author trust + docker/owner attribution, GitHub App setup issuer binding, real cancel kill, wider shell env exclusion
- CODEX_GLOBAL_ADMINS replaces maintainer-derived global admin;
  admin endpoints (quotas, sweep, backup, push dispatch) fail-closed
  403 when unset, with audit on allow and deny.
- continue/followups/messages/retry check both caller and original
  creator quotas (USD + unpriced token cap), 402 on either overrun.
- Quota-less users now get the #86 default quota instead of unlimited;
  init_db backfills and OAuth create/bind/existing-login branches
  INSERT OR IGNORE a default row; explicit NULL row stays unlimited.
  user_quotas added to migration tables.
- /fix-review: untrusted authors rejected before dedup claim, no task;
  trusted tasks force executor=docker, created_by=project owner, owner
  quota gate, prompt is a fixed template plus a 2000-char excerpt.
- GitHub App setup state only issued by global admins, state rows bind
  issued_by (ALTER-compatible for old DBs), callback rejects issuers
  no longer in the admin list without consuming the state.
- Cancel: popen poll checks DB status every second, Docker path runs a
  cancel watchdog that kills by container name, write-back is CAS-guarded
  so late results never overwrite a new status.
- SHELL_ENV_EXCLUDE adds *_TOKEN, *_AUTH*, *_CREDENTIAL*, *API_KEY*;
  comment states the residual risk: same-UID /proc environ remains
  readable, true isolation needs UID separation or hidepid.
- Tests: new cloud/tests/test_p0_security.py (17 cases); old tests
  updated to the new semantics (admin env, default quota, trusted
  fix-review author) without weakening any gate.
2026-10-27 15:35:23 +00:00

398 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""外部集成 webhook 与推送通知模块(云端架构实施)。
零第三方依赖,只用 Python 标准库。
本模块只做两件事:
1. 入站 webhook 的签名校验与事件解析(GitHub、Slack)。
2. 出站状态通知的占位分发(webhook + Slack)。
边界(不要超出本模块宣称的范围):
- Web Push / APNs / FCM 明确是后续需要证书的一步,本版只有
webhook + Slack 占位,不要编造已接通手机推送。
- 密钥走环境变量是调用方的事:本模块的校验函数只接受参数,
不自己读生产密钥,也不把任何密钥写入文件。
- 本模块不联网、不调用真实 GitHub/Slack、不跑真实模型回合。
"""
import hashlib
import hmac
import sqlite3
import time
from cloud.db import connect as _db_connect
__all__ = [
"verify_github_signature",
"is_trusted_fix_review_author",
"parse_github_pr_comment",
"parse_github_check_run",
"WebhookDeliveryStore",
"verify_slack_signature",
"parse_slack_command",
"SlackUserMap",
"ALLOWED_NOTIFICATION_EVENTS",
"NotificationService",
]
def verify_github_signature(secret, payload_bytes, signature_header):
"""校验 GitHub webhook 签名。
signature_header 形如 ``"sha256=<hex>"``,用
``hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()``
比对,比较用 ``hmac.compare_digest``。缺 secret 或缺 header 返回 False。
"""
if not secret or not signature_header:
return False
if isinstance(payload_bytes, str):
payload_bytes = payload_bytes.encode("utf-8")
if not isinstance(payload_bytes, (bytes, bytearray)):
return False
if not isinstance(signature_header, str):
return False
try:
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), bytes(payload_bytes), hashlib.sha256
).hexdigest()
except (AttributeError, TypeError, ValueError):
return False
return hmac.compare_digest(expected, signature_header)
def verify_github_signature_any(secrets, payload_bytes, signature_header):
"""多 secret 验签:任一 secret 对上即 True,全不对或无 secret
即 False。过渡期手动 webhook(env secret)与 App webhook
(库里 secret)共存时用;App 装好、手动 webhook 删掉后,
env secret 自然不再被命中,不用改代码。"""
if not secrets:
return False
for secret in secrets:
if secret and verify_github_signature(
secret, payload_bytes, signature_header):
return True
return False
def parse_github_pr_comment(payload):
"""解析 GitHub issue_comment 事件,返回 fix-review 触发点或 None。
这是 PR 评论 → fix-review 的触发点,真正执行 fix-review 由控制面调
host.py 完成,本函数只解析不执行。
只有当 action == "created" 且 comment body 里含 "/fix-review" 时返回::
{"type": "fix_review", "repo": ..., "pr_number": ...,
"comment": ..., "author": ...}
其中 repo 取 payload repository full_name,pr_number 取 issue number,
comment 取 body,author 取 comment user login。其他情况返回 None。
"""
if not isinstance(payload, dict):
return None
if payload.get("action") != "created":
return None
comment = payload.get("comment")
if not isinstance(comment, dict):
return None
body = comment.get("body")
if not isinstance(body, str) or "/fix-review" not in body:
return None
repository = payload.get("repository") or {}
issue = payload.get("issue") or {}
user = comment.get("user") or {}
return {
"type": "fix_review",
"repo": repository.get("full_name"),
"pr_number": issue.get("number"),
"comment": body,
"author": user.get("login"),
}
# GitHub 在 issue_comment / pull_request_review_comment 事件里
# 自带 author_association(由 GitHub 设定,不是评论者自填):
# OWNER / MEMBER / COLLABORATOR 是仓库可信身份;CONTRIBUTOR
# 及以下不是。PR/issue 作者本人评论自己的 PR 也可信(作者
# 只能在自己的 PR 上触发,不构成跨仓库免费入口)。
_TRUSTED_FIX_REVIEW_ASSOCIATIONS = frozenset(
{"OWNER", "MEMBER", "COLLABORATOR"})
def is_trusted_fix_review_author(payload):
"""P0 安全批:/fix-review 评论者是否可信。
只认两条,任一成立即 True,其余一律 False(fail-closed):
1. comment.user.author_association 在可信集里;
2. 评论者 login 等于 issue 作者或 pull_request 作者。
association 缺失且作者比对不上时返回 False——此时不要
用 App token 现查协作者(本函数不联网),直接拒单,
绝不因“查不到”就放行。这是 GitHub 事件字段校验,不
是实时协作者 API 查询,部署文档里必须如实写清这一点。
"""
if not isinstance(payload, dict):
return False
comment = payload.get("comment")
if not isinstance(comment, dict):
return False
user = comment.get("user") or {}
author = user.get("login")
if not author:
return False
if user.get("author_association") in _TRUSTED_FIX_REVIEW_ASSOCIATIONS:
return True
issue = payload.get("issue") or {}
issue_user = issue.get("user") or {}
if author == issue_user.get("login"):
return True
pull = payload.get("pull_request") or {}
pull_user = pull.get("user") or {}
if author == pull_user.get("login"):
return True
return False
def parse_github_check_run(payload):
"""解析 GitHub check_run / check_suite 事件,返回结构化结果或 None。
只在结论已定的时候返回:check_run / check_suite 的 conclusion
为 "success" 或 "failure" 时返回::
{"type": "check_run", "repo": ..., "head_sha": ...,
"conclusion": ..., "check_name": ...}
其中 repo 取 repository full_name,head_sha 取 check_run /
check_suite 的 head_sha,check_name 取 check_run 的 name
(check_suite 没有单项名时为 None)。结论为 None / 进行中
(如 queued / in_progress / neutral / cancelled 等)时返回 None,
调用方不应据此建任务或改任务状态——CI 状态回填由 pr-checks
主动拉取,webhook 只做通知解析。
"""
if not isinstance(payload, dict):
return None
for key in ("check_run", "check_suite"):
run = payload.get(key)
if not isinstance(run, dict):
continue
conclusion = run.get("conclusion")
if conclusion not in ("success", "failure"):
return None
repository = payload.get("repository") or {}
return {
"type": "check_run",
"repo": repository.get("full_name"),
"head_sha": run.get("head_sha"),
"conclusion": conclusion,
"check_name": run.get("name"),
}
return None
class WebhookDeliveryStore:
"""GitHub webhook delivery 去重(持久化,不只放内存)。
表 webhook_deliveries:delivery_id(主键,X-GitHub-Delivery 头)、
event、repo、created_at(epoch 秒)。claim() 第一次返回 True,
同一 delivery_id 第二次返回 False,调用方应直接回 200 且标记
duplicate,不重复建任务、不重复记审计。INSERT 冲突时先按
IntegrityError 处理,再回查一次确认(PG 适配层抛的完整性错误
类型与 SQLite 不同,这里按“插入失败后回查存在即重复”处理,
不依赖具体异常类名)。
"""
def __init__(self, db_path):
self.db_path = db_path
self._conn = _db_connect(db_path)
self._conn.execute(
"CREATE TABLE IF NOT EXISTS webhook_deliveries("
"delivery_id TEXT PRIMARY KEY, "
"event TEXT, "
"repo TEXT, "
"created_at REAL)"
)
self._conn.commit()
def claim(self, delivery_id, event=None, repo=None):
"""认领一次 delivery:首次 True,重复 False。空 id 不认领。"""
if not delivery_id:
return True
row = self._conn.execute(
"SELECT delivery_id FROM webhook_deliveries WHERE delivery_id = ?",
(delivery_id,),
).fetchone()
if row is not None:
return False
try:
self._conn.execute(
"INSERT INTO webhook_deliveries"
"(delivery_id, event, repo, created_at)"
" VALUES(?, ?, ?, ?)",
(delivery_id, event, repo, time.time()),
)
self._conn.commit()
except Exception:
try:
self._conn.rollback()
except Exception:
pass
row = self._conn.execute(
"SELECT delivery_id FROM webhook_deliveries"
" WHERE delivery_id = ?",
(delivery_id,),
).fetchone()
if row is not None:
return False
raise
return True
def close(self):
"""关闭数据库连接(可选,单测/退出时用)。"""
self._conn.close()
def verify_slack_signature(signing_secret, timestamp, body, signature_header):
"""校验 Slack 请求签名(防重放)。
Slack 规则:basestring = ``f"v0:{timestamp}:{body}"``,hmac sha256,
header 形如 ``"v0=<hex>"``。时间戳与当前时间差超过 300 秒返回 False
(防重放);缺参返回 False。
"""
if not signing_secret or timestamp is None or body is None:
return False
if not signature_header:
return False
try:
ts_int = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - ts_int) > 300:
return False
try:
if isinstance(body, (bytes, bytearray)):
basestring = b"v0:" + str(ts_int).encode("utf-8") + b":" + bytes(body)
expected = "v0=" + hmac.new(
signing_secret.encode("utf-8"), basestring, hashlib.sha256
).hexdigest()
else:
basestring = f"v0:{timestamp}:{body}"
expected = "v0=" + hmac.new(
signing_secret.encode("utf-8"),
basestring.encode("utf-8"),
hashlib.sha256,
).hexdigest()
except (AttributeError, TypeError, ValueError):
return False
if not isinstance(signature_header, str):
return False
return hmac.compare_digest(expected, signature_header)
def parse_slack_command(text):
"""解析 Slack 斜杠命令。
- ``"/codex 做一个加法函数"`` ->
``{"command": "codex", "prompt": "做一个加法函数"}``
- ``"/codex status <任务名>"`` ->
``{"command": "status", "task": "<任务名>"}``
- 空或非 ``/codex`` 开头 -> ``{"command": None, "prompt": text}``
"""
if not text or not isinstance(text, str):
return {"command": None, "prompt": text}
stripped = text.strip()
if stripped == "/codex" or stripped.startswith("/codex "):
rest = stripped[len("/codex"):].strip()
if rest == "status" or rest.startswith("status "):
task = rest[len("status"):].strip()
return {"command": "status", "task": task}
return {"command": "codex", "prompt": rest}
return {"command": None, "prompt": text}
class SlackUserMap:
"""Slack 用户 ID 到内部 user_id 的映射(SQLite)。"""
def __init__(self, db_path):
self.db_path = db_path
self._conn = _db_connect(db_path)
self._conn.execute(
"CREATE TABLE IF NOT EXISTS slack_users ("
"slack_id TEXT PRIMARY KEY, "
"user_id TEXT, "
"created_at REAL)"
)
self._conn.commit()
def map(self, slack_id, user_id):
"""把 slack_id 映射到 user_id(已存在则覆盖)。"""
self._conn.execute(
"INSERT OR REPLACE INTO slack_users "
"(slack_id, user_id, created_at) VALUES (?, ?, ?)",
(slack_id, user_id, time.time()),
)
self._conn.commit()
def lookup(self, slack_id):
"""返回 slack_id 对应的 user_id,未映射返回 None。"""
row = self._conn.execute(
"SELECT user_id FROM slack_users WHERE slack_id = ?",
(slack_id,),
).fetchone()
return row[0] if row else None
def close(self):
"""关闭数据库连接(可选,单测/退出时用)。"""
self._conn.close()
# 只发状态变化的事件集合:进行中的状态(如 running)不触发通知。
ALLOWED_NOTIFICATION_EVENTS = {
"completed",
"failed",
"goal-unmet",
"merge-failed",
"review-requested",
}
class NotificationService:
"""状态通知分发(webhook + Slack 占位)。
Web Push / APNs / FCM 明确是后续需要证书的一步,本版只有 webhook +
Slack 占位,不要编造已接通手机推送。
sender 都是可调用对象,单测可注入假 sender,默认 None 时不发、
不报错。sender 被调用时收到一个 dict payload::
{"event_type": ..., "task_id": ..., "project_id": ..., "detail": ...}
"""
def __init__(self, webhook_sender=None, slack_sender=None):
self.webhook_sender = webhook_sender
self.slack_sender = slack_sender
def notify(self, event_type, task_id, project_id, detail=""):
"""发送一条状态通知。
event_type 不在 ALLOWED_NOTIFICATION_EVENTS 里时返回
``{"sent": False, "reason": "event not allowed"}`` 且不调用 sender;
否则依次调 webhook_sender 和 slack_sender(不为 None 的才调),
返回 ``{"sent": True, "channels": [...已调的通道名...]}``。
"""
if event_type not in ALLOWED_NOTIFICATION_EVENTS:
return {"sent": False, "reason": "event not allowed"}
payload = {
"event_type": event_type,
"task_id": task_id,
"project_id": project_id,
"detail": detail,
}
channels = []
if self.webhook_sender is not None:
self.webhook_sender(payload)
channels.append("webhook")
if self.slack_sender is not None:
self.slack_sender(payload)
channels.append("slack")
return {"sent": True, "channels": channels}