fix(eval): bind dispatch markers to workflow artifacts
Bind each accepted marker to the exact workflow run attempt, immutable artifact, source comment, and current PR head so a historical successful run cannot authorize a different payload.
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
name: PR Eval Dispatch
|
||||
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 触发内网评测流水线,报告由内网 bot 回贴。
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
|
||||
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
|
||||
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
|
||||
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);内网 CI 另以
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
|
||||
# FETCH_HEAD 校验兜底派发后的变更。
|
||||
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
|
||||
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
|
||||
# 触发通道:workflow 校验通过后发结构化评论(HTML 注释内嵌 JSON),
|
||||
# 内网 Devix 轮询服务每 3 分钟扫描 → 触发 Aone CI 流水线。
|
||||
# 不再直接 curl 内网(公网 runner 结构性不可达)。
|
||||
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
|
||||
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
|
||||
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
|
||||
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -93,36 +94,217 @@ jobs:
|
||||
| python3 scripts/ci/eval_dispatch_guard.py head \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post dispatch marker and acknowledge
|
||||
- name: Create dispatch placeholder
|
||||
id: placeholder
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
jq -n --arg body "$placeholder_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
| jq -er \
|
||||
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
|
||||
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
|
||||
)"
|
||||
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build dispatch request manifest
|
||||
env:
|
||||
REPOSITORY_ID: '1187709537'
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
ACTOR_ID: ${{ github.event.comment.user.id }}
|
||||
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
SOURCE_BODY: ${{ github.event.comment.body }}
|
||||
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
|
||||
echo "unexpected repository: ${REPOSITORY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
for value in \
|
||||
"$REPOSITORY_ID" \
|
||||
"$WORKFLOW_ID" \
|
||||
"$RUN_ID" \
|
||||
"$RUN_ATTEMPT" \
|
||||
"$SOURCE_COMMENT_ID" \
|
||||
"$DISPATCH_COMMENT_ID" \
|
||||
"$ACTOR_ID" \
|
||||
"$PR_NUMBER"; do
|
||||
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "dispatch manifest contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "dispatch manifest contains an invalid PR head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
|
||||
source_body_sha256="${hash_output%% *}"
|
||||
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "failed to hash source comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
|
||||
|
||||
umask 077
|
||||
jq -n \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg repository "$REPOSITORY" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg source_comment_id "$SOURCE_COMMENT_ID" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg actor_id "$ACTOR_ID" \
|
||||
--arg actor_login "$ACTOR_LOGIN" \
|
||||
--arg pr_number "$PR_NUMBER" \
|
||||
--arg pr_head_sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases_ref "$CASES_REF" \
|
||||
--arg source_body_sha256 "$source_body_sha256" \
|
||||
--arg idempotency_key "$idempotency_key" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
repository: $repository,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
source_comment_id: $source_comment_id,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
actor_id: $actor_id,
|
||||
actor_login: $actor_login,
|
||||
pr_number: $pr_number,
|
||||
pr_head_sha: $pr_head_sha,
|
||||
products: $products,
|
||||
cases_ref: $cases_ref,
|
||||
source_body_sha256: $source_body_sha256,
|
||||
idempotency_key: $idempotency_key
|
||||
}' > "$MANIFEST_PATH"
|
||||
|
||||
- name: Upload dispatch request manifest
|
||||
id: artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
|
||||
path: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: false
|
||||
|
||||
- name: Finalize dispatch marker
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
REPOSITORY_ID: '1187709537'
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
|
||||
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
|
||||
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "artifact marker contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
artifact_digest="${ARTIFACT_DIGEST,,}"
|
||||
if [[ "$artifact_digest" != sha256:* ]]; then
|
||||
artifact_digest="sha256:${artifact_digest}"
|
||||
fi
|
||||
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "artifact marker contains an invalid digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
marker_json="$(
|
||||
jq -nc \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg artifact_id "$ARTIFACT_ID" \
|
||||
--arg artifact_digest "$artifact_digest" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
artifact_id: $artifact_id,
|
||||
artifact_digest: $artifact_digest
|
||||
}'
|
||||
)"
|
||||
cases_note=""
|
||||
if [ -n "$CASES_REF" ]; then
|
||||
cases_note=",用例版本 \`${CASES_REF}\`"
|
||||
fi
|
||||
# 安全模型:结构化标记供内网轮询服务提取并触发 Aone CI。
|
||||
# 防伪造保证由消费端三重校验(见 scripts/ci/eval_poll_validate.py):
|
||||
# 1. comment.user.login == 'github-actions[bot]'(平台级身份,不可冒充)
|
||||
# 2. comment.performed_via_github_app.slug == 'github-actions'(App 签名)
|
||||
# 3. payload.run_id 对应真实成功的 workflow run(GET /actions/runs/{id})
|
||||
# 任意一层不通过即丢弃,普通用户手写评论无法通过上述校验。
|
||||
dispatch_payload=$(jq -nc \
|
||||
--arg pr "$PR_NUMBER" \
|
||||
--arg sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases "$CASES_REF" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
'{pr_number: $pr, pr_head_sha: $sha, products: $products, cases_ref: $cases, run_id: $run_id}')
|
||||
body="<!-- eval-dispatch: ${dispatch_payload} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"内网评测流水线将在数分钟内由轮询服务触发,运行结束后由 bot 回贴报告(首行为基线对比头条)。"
|
||||
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
jq -n --arg body "$body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
--arg comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg body "$body" \
|
||||
'((.id | tostring) == $comment_id) and (.body == $body)' \
|
||||
> /dev/null
|
||||
|
||||
- name: Mark dispatch preparation failure
|
||||
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
jq -n --arg body "$failure_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
|
||||
+478
-109
@@ -1,144 +1,513 @@
|
||||
"""eval_poll_validate.py — 消费端校验 eval-dispatch 结构化评论的合法性。
|
||||
#!/usr/bin/env python3
|
||||
"""验证 GitHub Actions 产出的 eval-dispatch artifact 请求。
|
||||
|
||||
三重防伪造校验:
|
||||
1. comment.user.login == 'github-actions[bot]' — 平台级身份,普通用户不可冒充
|
||||
2. comment.performed_via_github_app.slug == 'github-actions' — GitHub App 签名
|
||||
3. payload.run_id 对应本仓库真实成功完成的 workflow run
|
||||
|
||||
任何一层不通过即返回 False,拒绝触发内网流水线。
|
||||
评论只是索引。真正的授权记录是绑定到精确 workflow run、run attempt 和
|
||||
dispatch comment ID 的不可变 artifact。任何缺失、冲突或异常都必须拒绝,
|
||||
不能让单条不可信评论终止轮询进程。校验成功时返回稳定的
|
||||
idempotency_key;调用方必须在触发评测前持久化、原子占用该 key,重复
|
||||
占用只能 no-op。本脚本无状态,不以 reaction 代替持久去重。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
import zipfile
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from eval_comment_parse import parse as parse_eval_comment
|
||||
|
||||
|
||||
REPO = "DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
REPOSITORY = "DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
REPOSITORY_ID = "1187709537"
|
||||
WORKFLOW_ID = "331725458"
|
||||
WORKFLOW_PATH = ".github/workflows/eval-dispatch.yml"
|
||||
DEFAULT_BRANCH = "main"
|
||||
TRUSTED_BOT_LOGIN = "github-actions[bot]"
|
||||
TRUSTED_APP_SLUG = "github-actions"
|
||||
ARTIFACT_FILENAME = "eval-dispatch-request.json"
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def validate_comment_author(comment: dict) -> bool:
|
||||
"""校验评论由 github-actions[bot] 通过 GitHub Actions App 发出。"""
|
||||
user = comment.get("user", {})
|
||||
if user.get("login") != TRUSTED_BOT_LOGIN:
|
||||
return False
|
||||
if user.get("type") != "Bot":
|
||||
return False
|
||||
app = comment.get("performed_via_github_app") or {}
|
||||
if app.get("slug") != TRUSTED_APP_SLUG:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_run_id(run_id, repo: str = REPO) -> bool:
|
||||
"""校验 run_id 对应真实存在且成功完成的 workflow run。"""
|
||||
if not isinstance(run_id, str) or not run_id.isdigit():
|
||||
return False
|
||||
result = subprocess.run(
|
||||
["gh", "api", f"repos/{repo}/actions/runs/{run_id}",
|
||||
"--jq", ".status + \"|\" + .conclusion + \"|\" + .name"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
parts = result.stdout.strip().split("|")
|
||||
if len(parts) < 3:
|
||||
return False
|
||||
status, conclusion, name = parts[0], parts[1], parts[2]
|
||||
if status != "completed" or conclusion != "success":
|
||||
return False
|
||||
if "Eval Dispatch" not in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_pr_head(pr_number: str, expected_sha: str, repo: str = REPO) -> bool:
|
||||
"""二次校验 PR 仍然 open 且 head SHA 一致(防 TOCTOU)。"""
|
||||
result = subprocess.run(
|
||||
["gh", "api", f"repos/{repo}/pulls/{pr_number}",
|
||||
"--jq", ".state + \"|\" + .head.sha"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
parts = result.stdout.strip().split("|")
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
state, current_sha = parts[0], parts[1]
|
||||
if state != "open":
|
||||
return False
|
||||
if current_sha != expected_sha:
|
||||
return False
|
||||
return True
|
||||
|
||||
MAX_COMMENT_BYTES = 64 * 1024
|
||||
MAX_API_JSON_BYTES = 1024 * 1024
|
||||
MAX_ARCHIVE_BYTES = 64 * 1024
|
||||
MAX_MANIFEST_BYTES = 64 * 1024
|
||||
MAX_PRODUCTS_BYTES = 512
|
||||
MAX_CASES_REF_BYTES = 1024
|
||||
|
||||
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_PRODUCTS_RE = re.compile(r"^[a-zA-Z0-9_,.-]+$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
_PRODUCTS_RE = re.compile(r"^[a-z0-9][a-z0-9-]*(?:,[a-z0-9][a-z0-9-]*)*$")
|
||||
_REF_CHARSET_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
|
||||
_LOGIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$")
|
||||
_MARKER_RE = re.compile(r"<!-- eval-dispatch: ([^\r\n]*?) -->")
|
||||
|
||||
_MARKER_FIELDS = {
|
||||
"schema_version",
|
||||
"repository_id",
|
||||
"workflow_id",
|
||||
"workflow_path",
|
||||
"run_id",
|
||||
"run_attempt",
|
||||
"dispatch_comment_id",
|
||||
"artifact_id",
|
||||
"artifact_digest",
|
||||
}
|
||||
_MANIFEST_FIELDS = {
|
||||
"schema_version",
|
||||
"repository_id",
|
||||
"repository",
|
||||
"workflow_id",
|
||||
"workflow_path",
|
||||
"run_id",
|
||||
"run_attempt",
|
||||
"source_comment_id",
|
||||
"dispatch_comment_id",
|
||||
"actor_id",
|
||||
"actor_login",
|
||||
"pr_number",
|
||||
"pr_head_sha",
|
||||
"products",
|
||||
"cases_ref",
|
||||
"source_body_sha256",
|
||||
"idempotency_key",
|
||||
}
|
||||
|
||||
|
||||
def extract_payload(body: str) -> Optional[dict]:
|
||||
"""从评论 body 提取 eval-dispatch JSON payload,严格要求为合法 dict 且字段类型正确。"""
|
||||
match = re.search(r"<!-- eval-dispatch: ({.*?}) -->", body)
|
||||
if not match:
|
||||
class GitHubClient(Protocol):
|
||||
"""consumer 所需的最小 GitHub 只读边界。"""
|
||||
|
||||
def get_run_attempt(self, run_id: str, run_attempt: str) -> Any: ...
|
||||
|
||||
def get_artifact(self, artifact_id: str) -> Any: ...
|
||||
|
||||
def download_artifact(self, artifact_id: str) -> bytes: ...
|
||||
|
||||
def get_comment(self, comment_id: str) -> Any: ...
|
||||
|
||||
def get_pull_request(self, pr_number: str) -> Any: ...
|
||||
|
||||
|
||||
class GitHubCLIClient:
|
||||
"""通过已认证的 gh CLI 读取 GitHub REST API。"""
|
||||
|
||||
def _json(self, endpoint: str) -> Any:
|
||||
result = subprocess.run(
|
||||
["gh", "api", endpoint],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("GitHub API 请求失败")
|
||||
if len(result.stdout.encode("utf-8")) > MAX_API_JSON_BYTES:
|
||||
raise ValueError("GitHub API JSON 响应过大")
|
||||
return _loads_no_duplicates(result.stdout)
|
||||
|
||||
def get_run_attempt(self, run_id: str, run_attempt: str) -> Any:
|
||||
return self._json(
|
||||
f"repos/{REPOSITORY}/actions/runs/{run_id}/attempts/{run_attempt}"
|
||||
)
|
||||
|
||||
def get_artifact(self, artifact_id: str) -> Any:
|
||||
return self._json(f"repos/{REPOSITORY}/actions/artifacts/{artifact_id}")
|
||||
|
||||
def download_artifact(self, artifact_id: str) -> bytes:
|
||||
result = subprocess.run(
|
||||
["gh", "api", f"repos/{REPOSITORY}/actions/artifacts/{artifact_id}/zip"],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("下载 GitHub artifact 失败")
|
||||
if len(result.stdout) > MAX_ARCHIVE_BYTES:
|
||||
raise ValueError("artifact 压缩包过大")
|
||||
return result.stdout
|
||||
|
||||
def get_comment(self, comment_id: str) -> Any:
|
||||
return self._json(f"repos/{REPOSITORY}/issues/comments/{comment_id}")
|
||||
|
||||
def get_pull_request(self, pr_number: str) -> Any:
|
||||
return self._json(f"repos/{REPOSITORY}/pulls/{pr_number}")
|
||||
|
||||
|
||||
def _loads_no_duplicates(raw: str) -> Any:
|
||||
def reject_duplicates(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"JSON 字段重复: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
return json.loads(raw, object_pairs_hook=reject_duplicates)
|
||||
|
||||
|
||||
def _is_decimal_string(value: Any, *, max_length: int = 20) -> bool:
|
||||
return (
|
||||
type(value) is str
|
||||
and 1 <= len(value) <= max_length
|
||||
and value.isascii()
|
||||
and value.isdigit()
|
||||
and value[0] != "0"
|
||||
)
|
||||
|
||||
|
||||
def _as_decimal_string(value: Any) -> Optional[str]:
|
||||
if type(value) is int and value > 0:
|
||||
return str(value)
|
||||
if _is_decimal_string(value):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _is_valid_cases_ref(value: Any) -> bool:
|
||||
if type(value) is not str or len(value.encode("utf-8")) > MAX_CASES_REF_BYTES:
|
||||
return False
|
||||
if value == "":
|
||||
return True
|
||||
if not _REF_CHARSET_RE.fullmatch(value):
|
||||
return False
|
||||
if value.startswith(("-", "/")) or value.endswith(("/", ".")):
|
||||
return False
|
||||
if ".." in value or "//" in value:
|
||||
return False
|
||||
return all(
|
||||
component
|
||||
and not component.startswith(".")
|
||||
and not component.endswith(".lock")
|
||||
for component in value.split("/")
|
||||
)
|
||||
|
||||
|
||||
def _is_string(value: Any, *, max_bytes: int, allow_empty: bool = False) -> bool:
|
||||
return (
|
||||
type(value) is str
|
||||
and (allow_empty or bool(value))
|
||||
and len(value.encode("utf-8")) <= max_bytes
|
||||
)
|
||||
|
||||
|
||||
def validate_comment_author(comment: Any) -> bool:
|
||||
"""校验 dispatch 评论由 GitHub Actions App 发出。"""
|
||||
if type(comment) is not dict:
|
||||
return False
|
||||
user = comment.get("user")
|
||||
app = comment.get("performed_via_github_app")
|
||||
return (
|
||||
type(user) is dict
|
||||
and user.get("login") == TRUSTED_BOT_LOGIN
|
||||
and user.get("type") == "Bot"
|
||||
and type(app) is dict
|
||||
and app.get("slug") == TRUSTED_APP_SLUG
|
||||
)
|
||||
|
||||
|
||||
def _extract_marker(body: Any) -> Optional[dict[str, Any]]:
|
||||
if not _is_string(body, max_bytes=MAX_COMMENT_BYTES):
|
||||
return None
|
||||
if body.count("<!-- eval-dispatch:") != 1:
|
||||
return None
|
||||
matches = _MARKER_RE.findall(body)
|
||||
if len(matches) != 1 or len(matches[0].encode("utf-8")) > 8 * 1024:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(match.group(1))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
marker = _loads_no_duplicates(matches[0])
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
if type(marker) is not dict or set(marker) != _MARKER_FIELDS:
|
||||
return None
|
||||
pr_number = data.get("pr_number")
|
||||
if not isinstance(pr_number, str) or not pr_number.isdigit():
|
||||
if type(marker.get("schema_version")) is not int or marker["schema_version"] != SCHEMA_VERSION:
|
||||
return None
|
||||
pr_head_sha = data.get("pr_head_sha")
|
||||
if not isinstance(pr_head_sha, str) or not _SHA_RE.match(pr_head_sha):
|
||||
for field, expected in (
|
||||
("repository_id", REPOSITORY_ID),
|
||||
("workflow_id", WORKFLOW_ID),
|
||||
("workflow_path", WORKFLOW_PATH),
|
||||
):
|
||||
if marker.get(field) != expected:
|
||||
return None
|
||||
for field in ("run_id", "run_attempt", "dispatch_comment_id", "artifact_id"):
|
||||
if not _is_decimal_string(marker.get(field)):
|
||||
return None
|
||||
if type(marker.get("artifact_digest")) is not str or not _DIGEST_RE.fullmatch(
|
||||
marker["artifact_digest"]
|
||||
):
|
||||
return None
|
||||
products = data.get("products")
|
||||
if not isinstance(products, str) or not _PRODUCTS_RE.match(products):
|
||||
return None
|
||||
run_id = data.get("run_id")
|
||||
if not isinstance(run_id, str) or not run_id.isdigit():
|
||||
return None
|
||||
cases_ref = data.get("cases_ref")
|
||||
if not isinstance(cases_ref, str):
|
||||
return None
|
||||
return data
|
||||
return marker
|
||||
|
||||
|
||||
def validate_comment(comment: dict, verify_run: bool = True, verify_pr: bool = True) -> Optional[dict]:
|
||||
"""完整校验流程,通过返回 payload dict,不通过返回 None。"""
|
||||
def _validate_run(run: Any, marker: dict[str, Any]) -> bool:
|
||||
if type(run) is not dict:
|
||||
return False
|
||||
repository = run.get("repository")
|
||||
head_repository = run.get("head_repository")
|
||||
return (
|
||||
_as_decimal_string(run.get("id")) == marker["run_id"]
|
||||
and _as_decimal_string(run.get("run_attempt")) == marker["run_attempt"]
|
||||
and _as_decimal_string(run.get("workflow_id")) == WORKFLOW_ID
|
||||
and run.get("path") == WORKFLOW_PATH
|
||||
and run.get("event") == "issue_comment"
|
||||
and run.get("head_branch") == DEFAULT_BRANCH
|
||||
and run.get("status") == "completed"
|
||||
and run.get("conclusion") == "success"
|
||||
and type(repository) is dict
|
||||
and _as_decimal_string(repository.get("id")) == REPOSITORY_ID
|
||||
and repository.get("full_name") == REPOSITORY
|
||||
and type(head_repository) is dict
|
||||
and _as_decimal_string(head_repository.get("id")) == REPOSITORY_ID
|
||||
and head_repository.get("full_name") == REPOSITORY
|
||||
and type(run.get("head_sha")) is str
|
||||
and bool(_SHA_RE.fullmatch(run["head_sha"]))
|
||||
)
|
||||
|
||||
|
||||
def _validate_artifact_metadata(
|
||||
artifact: Any, marker: dict[str, Any], run: dict[str, Any]
|
||||
) -> bool:
|
||||
if type(artifact) is not dict:
|
||||
return False
|
||||
workflow_run = artifact.get("workflow_run")
|
||||
expected_name = (
|
||||
f"eval-dispatch-request-{marker['run_id']}-{marker['run_attempt']}-"
|
||||
f"{marker['dispatch_comment_id']}"
|
||||
)
|
||||
size = artifact.get("size_in_bytes")
|
||||
return (
|
||||
_as_decimal_string(artifact.get("id")) == marker["artifact_id"]
|
||||
and artifact.get("name") == expected_name
|
||||
and artifact.get("expired") is False
|
||||
and artifact.get("digest") == marker["artifact_digest"]
|
||||
and type(size) is int
|
||||
and 0 < size <= MAX_ARCHIVE_BYTES
|
||||
and type(workflow_run) is dict
|
||||
and _as_decimal_string(workflow_run.get("id")) == marker["run_id"]
|
||||
and _as_decimal_string(workflow_run.get("repository_id")) == REPOSITORY_ID
|
||||
and _as_decimal_string(workflow_run.get("head_repository_id")) == REPOSITORY_ID
|
||||
and workflow_run.get("head_sha") == run.get("head_sha")
|
||||
)
|
||||
|
||||
|
||||
def _read_manifest(archive_bytes: Any, expected_digest: str) -> Optional[dict[str, Any]]:
|
||||
if type(archive_bytes) is not bytes or not 0 < len(archive_bytes) <= MAX_ARCHIVE_BYTES:
|
||||
return None
|
||||
actual_digest = "sha256:" + hashlib.sha256(archive_bytes).hexdigest()
|
||||
if actual_digest != expected_digest:
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as archive:
|
||||
infos = archive.infolist()
|
||||
if len(infos) != 1:
|
||||
return None
|
||||
info = infos[0]
|
||||
if info.filename != ARTIFACT_FILENAME or info.is_dir():
|
||||
return None
|
||||
if info.flag_bits & 0x1:
|
||||
return None
|
||||
if info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
|
||||
return None
|
||||
mode = (info.external_attr >> 16) & 0o177777
|
||||
file_type = stat.S_IFMT(mode)
|
||||
if file_type not in (0, stat.S_IFREG):
|
||||
return None
|
||||
if not 0 < info.file_size <= MAX_MANIFEST_BYTES:
|
||||
return None
|
||||
raw_manifest = archive.read(info)
|
||||
except (EOFError, OSError, RuntimeError, ValueError, zipfile.BadZipFile):
|
||||
return None
|
||||
if not 0 < len(raw_manifest) <= MAX_MANIFEST_BYTES:
|
||||
return None
|
||||
try:
|
||||
manifest = _loads_no_duplicates(raw_manifest.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
if type(manifest) is not dict or set(manifest) != _MANIFEST_FIELDS:
|
||||
return None
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_manifest_shape(manifest: dict[str, Any]) -> bool:
|
||||
if (
|
||||
type(manifest.get("schema_version")) is not int
|
||||
or manifest["schema_version"] != SCHEMA_VERSION
|
||||
):
|
||||
return False
|
||||
for field, expected in (
|
||||
("repository_id", REPOSITORY_ID),
|
||||
("repository", REPOSITORY),
|
||||
("workflow_id", WORKFLOW_ID),
|
||||
("workflow_path", WORKFLOW_PATH),
|
||||
):
|
||||
if manifest.get(field) != expected:
|
||||
return False
|
||||
for field in (
|
||||
"run_id",
|
||||
"run_attempt",
|
||||
"source_comment_id",
|
||||
"dispatch_comment_id",
|
||||
"actor_id",
|
||||
"pr_number",
|
||||
):
|
||||
if not _is_decimal_string(manifest.get(field)):
|
||||
return False
|
||||
if type(manifest.get("actor_login")) is not str or not _LOGIN_RE.fullmatch(
|
||||
manifest["actor_login"]
|
||||
):
|
||||
return False
|
||||
if type(manifest.get("pr_head_sha")) is not str or not _SHA_RE.fullmatch(
|
||||
manifest["pr_head_sha"]
|
||||
):
|
||||
return False
|
||||
products = manifest.get("products")
|
||||
if (
|
||||
type(products) is not str
|
||||
or len(products.encode("utf-8")) > MAX_PRODUCTS_BYTES
|
||||
or not _PRODUCTS_RE.fullmatch(products)
|
||||
):
|
||||
return False
|
||||
if not _is_valid_cases_ref(manifest.get("cases_ref")):
|
||||
return False
|
||||
if type(manifest.get("source_body_sha256")) is not str or not _SHA256_RE.fullmatch(
|
||||
manifest["source_body_sha256"]
|
||||
):
|
||||
return False
|
||||
expected_key = f"{REPOSITORY_ID}:{manifest['source_comment_id']}"
|
||||
return manifest.get("idempotency_key") == expected_key
|
||||
|
||||
|
||||
def _expected_issue_url(pr_number: str) -> str:
|
||||
return f"https://api.github.com/repos/{REPOSITORY}/issues/{pr_number}"
|
||||
|
||||
|
||||
def _validate_source_comment(source: Any, manifest: dict[str, Any]) -> bool:
|
||||
if type(source) is not dict:
|
||||
return False
|
||||
user = source.get("user")
|
||||
body = source.get("body")
|
||||
if (
|
||||
_as_decimal_string(source.get("id")) != manifest["source_comment_id"]
|
||||
or source.get("issue_url") != _expected_issue_url(manifest["pr_number"])
|
||||
or type(user) is not dict
|
||||
or _as_decimal_string(user.get("id")) != manifest["actor_id"]
|
||||
or user.get("login") != manifest["actor_login"]
|
||||
or not _is_string(body, max_bytes=MAX_COMMENT_BYTES)
|
||||
or hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||
!= manifest["source_body_sha256"]
|
||||
):
|
||||
return False
|
||||
try:
|
||||
products, cases_ref, reviewed_sha = parse_eval_comment(body)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
products == manifest["products"]
|
||||
and cases_ref == manifest["cases_ref"]
|
||||
and (not reviewed_sha or reviewed_sha == manifest["pr_head_sha"])
|
||||
)
|
||||
|
||||
|
||||
def _validate_run_actor(run: dict[str, Any], manifest: dict[str, Any]) -> bool:
|
||||
actor = run.get("actor")
|
||||
return (
|
||||
type(actor) is dict
|
||||
and _as_decimal_string(actor.get("id")) == manifest["actor_id"]
|
||||
and actor.get("login") == manifest["actor_login"]
|
||||
)
|
||||
|
||||
|
||||
def _validate_pull_request(pr: Any, manifest: dict[str, Any]) -> bool:
|
||||
if type(pr) is not dict:
|
||||
return False
|
||||
head = pr.get("head")
|
||||
return (
|
||||
_as_decimal_string(pr.get("number")) == manifest["pr_number"]
|
||||
and pr.get("state") == "open"
|
||||
and type(head) is dict
|
||||
and head.get("sha") == manifest["pr_head_sha"]
|
||||
)
|
||||
|
||||
|
||||
def _validate_comment(comment: Any, client: GitHubClient) -> Optional[dict[str, str]]:
|
||||
if not validate_comment_author(comment):
|
||||
print(f" REJECT: 评论作者不是可信 bot (id={comment.get('id')})")
|
||||
return None
|
||||
dispatch_comment_id = _as_decimal_string(comment.get("id"))
|
||||
marker = _extract_marker(comment.get("body"))
|
||||
if dispatch_comment_id is None or marker is None:
|
||||
return None
|
||||
if marker["dispatch_comment_id"] != dispatch_comment_id:
|
||||
return None
|
||||
|
||||
payload = extract_payload(comment.get("body", ""))
|
||||
if not payload:
|
||||
run = client.get_run_attempt(marker["run_id"], marker["run_attempt"])
|
||||
if not _validate_run(run, marker):
|
||||
return None
|
||||
|
||||
if verify_run:
|
||||
run_id = payload.get("run_id", "")
|
||||
if not validate_run_id(run_id):
|
||||
print(f" REJECT: run_id={run_id} 不是有效的成功 workflow run")
|
||||
return None
|
||||
artifact = client.get_artifact(marker["artifact_id"])
|
||||
if not _validate_artifact_metadata(artifact, marker, run):
|
||||
return None
|
||||
archive = client.download_artifact(marker["artifact_id"])
|
||||
manifest = _read_manifest(archive, marker["artifact_digest"])
|
||||
if manifest is None or not _validate_manifest_shape(manifest):
|
||||
return None
|
||||
|
||||
if verify_pr:
|
||||
pr_number = payload.get("pr_number", "")
|
||||
pr_head_sha = payload.get("pr_head_sha", "")
|
||||
if not validate_pr_head(pr_number, pr_head_sha):
|
||||
print(f" REJECT: PR #{pr_number} 非 open 或 head 已变更")
|
||||
for field in (
|
||||
"schema_version",
|
||||
"repository_id",
|
||||
"workflow_id",
|
||||
"workflow_path",
|
||||
"run_id",
|
||||
"run_attempt",
|
||||
"dispatch_comment_id",
|
||||
):
|
||||
if manifest.get(field) != marker.get(field):
|
||||
return None
|
||||
if comment.get("issue_url") != _expected_issue_url(manifest["pr_number"]):
|
||||
return None
|
||||
if not _validate_run_actor(run, manifest):
|
||||
return None
|
||||
|
||||
return payload
|
||||
source_comment = client.get_comment(manifest["source_comment_id"])
|
||||
if not _validate_source_comment(source_comment, manifest):
|
||||
return None
|
||||
|
||||
pr = client.get_pull_request(manifest["pr_number"])
|
||||
if not _validate_pull_request(pr, manifest):
|
||||
return None
|
||||
|
||||
return {
|
||||
"pr_number": manifest["pr_number"],
|
||||
"pr_head_sha": manifest["pr_head_sha"],
|
||||
"products": manifest["products"],
|
||||
"cases_ref": manifest["cases_ref"],
|
||||
"idempotency_key": manifest["idempotency_key"],
|
||||
}
|
||||
|
||||
|
||||
def validate_comment(
|
||||
comment: Any, client: Optional[GitHubClient] = None
|
||||
) -> Optional[dict[str, str]]:
|
||||
"""完整校验一条 dispatch 评论;调用方须原子占用返回的幂等键。"""
|
||||
try:
|
||||
return _validate_comment(comment, client or GitHubCLIClient())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
comment = _loads_no_duplicates(sys.stdin.read())
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError):
|
||||
return 1
|
||||
result = validate_comment(comment)
|
||||
if result is None:
|
||||
return 1
|
||||
print(json.dumps(result, ensure_ascii=False, separators=(",", ":"), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
comment_json = json.load(sys.stdin)
|
||||
result = validate_comment(comment_json)
|
||||
if result:
|
||||
print(json.dumps(result))
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
sys.exit(main())
|
||||
|
||||
@@ -1,173 +1,762 @@
|
||||
"""test_eval_poll_validate.py — 验证消费端拒绝伪造的 eval-dispatch 评论。"""
|
||||
"""eval-dispatch 消费端安全协议的无网络行为测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "scripts", "ci"))
|
||||
from eval_poll_validate import validate_comment_author, extract_payload, validate_run_id
|
||||
import stat
|
||||
import sys
|
||||
import unittest
|
||||
import warnings
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def test_rejects_regular_user_comment():
|
||||
"""普通用户手写 eval-dispatch 标记的评论必须被拒绝。"""
|
||||
comment = {
|
||||
"id": 1234,
|
||||
"user": {"login": "malicious-user", "type": "User"},
|
||||
"body": '<!-- eval-dispatch: {"pr_number":"899","pr_head_sha":"a"*40,"products":"drive","cases_ref":"","run_id":"99999"} -->',
|
||||
"performed_via_github_app": None,
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from eval_poll_validate import GitHubCLIClient, main as validate_main, validate_comment
|
||||
|
||||
|
||||
REPOSITORY = "DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
REPOSITORY_ID = "1187709537"
|
||||
WORKFLOW_ID = "331725458"
|
||||
WORKFLOW_PATH = ".github/workflows/eval-dispatch.yml"
|
||||
RUN_ID = "31490000000"
|
||||
RUN_ATTEMPT = "1"
|
||||
SOURCE_COMMENT_ID = "5250000001"
|
||||
DISPATCH_COMMENT_ID = "5250000002"
|
||||
ARTIFACT_ID = "9100000001"
|
||||
ACTOR_ID = "30925823"
|
||||
ACTOR_LOGIN = "trusted-reviewer"
|
||||
PR_NUMBER = "952"
|
||||
PR_HEAD_SHA = "a" * 40
|
||||
DEFAULT_BRANCH_SHA = "b" * 40
|
||||
PRODUCTS = "drive,doc"
|
||||
CASES_REF = "fixtures/v1"
|
||||
SOURCE_BODY = f"/eval {PRODUCTS} sha={PR_HEAD_SHA} cases={CASES_REF}"
|
||||
ARTIFACT_FILENAME = "eval-dispatch-request.json"
|
||||
|
||||
|
||||
def _compact_json(value: dict) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def _zip_single_file(name: str, content: bytes) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(name, content)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _zip_files(
|
||||
entries: tuple[tuple[str, bytes], ...], *, compression=zipfile.ZIP_DEFLATED
|
||||
) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", UserWarning)
|
||||
with zipfile.ZipFile(output, "w", compression=compression) as archive:
|
||||
for name, content in entries:
|
||||
archive.writestr(name, content)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _zip_symlink(name: str, target: str) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive.writestr(info, target.encode("utf-8"))
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _set_marker(comment: dict, marker: dict) -> None:
|
||||
comment["body"] = f"<!-- eval-dispatch: {_compact_json(marker)} -->\n已受理。"
|
||||
|
||||
|
||||
def _install_archive(comment: dict, client, marker: dict, archive: bytes) -> None:
|
||||
digest = "sha256:" + hashlib.sha256(archive).hexdigest()
|
||||
client.archive = archive
|
||||
client.artifact["size_in_bytes"] = len(archive)
|
||||
client.artifact["digest"] = digest
|
||||
marker["artifact_digest"] = digest
|
||||
_set_marker(comment, marker)
|
||||
|
||||
|
||||
def _install_manifest(
|
||||
comment: dict, client, manifest: dict, marker: dict
|
||||
) -> None:
|
||||
archive = _zip_single_file(
|
||||
ARTIFACT_FILENAME,
|
||||
(_compact_json(manifest) + "\n").encode("utf-8"),
|
||||
)
|
||||
_install_archive(comment, client, marker, archive)
|
||||
|
||||
|
||||
def _set_nested(value: dict, path: tuple[str, ...], replacement) -> None:
|
||||
current = value
|
||||
for component in path[:-1]:
|
||||
current = current[component]
|
||||
current[path[-1]] = replacement
|
||||
|
||||
|
||||
class FakeGitHubClient:
|
||||
"""只模拟 consumer 的 GitHub 外部边界,不模拟内部校验步骤。"""
|
||||
|
||||
def __init__(self, *, run, artifact, archive, source_comment, pr):
|
||||
self.run = run
|
||||
self.artifact = artifact
|
||||
self.archive = archive
|
||||
self.source_comment = source_comment
|
||||
self.pr = pr
|
||||
self.calls = []
|
||||
|
||||
def get_run_attempt(self, run_id: str, run_attempt: str):
|
||||
self.calls.append(("get_run_attempt", run_id, run_attempt))
|
||||
return copy.deepcopy(self.run)
|
||||
|
||||
def get_artifact(self, artifact_id: str):
|
||||
self.calls.append(("get_artifact", artifact_id))
|
||||
return copy.deepcopy(self.artifact)
|
||||
|
||||
def download_artifact(self, artifact_id: str):
|
||||
self.calls.append(("download_artifact", artifact_id))
|
||||
return self.archive
|
||||
|
||||
def get_comment(self, comment_id: str):
|
||||
self.calls.append(("get_comment", comment_id))
|
||||
return copy.deepcopy(self.source_comment)
|
||||
|
||||
def get_pull_request(self, pr_number: str):
|
||||
self.calls.append(("get_pull_request", pr_number))
|
||||
return copy.deepcopy(self.pr)
|
||||
|
||||
|
||||
def valid_fixture():
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"repository_id": REPOSITORY_ID,
|
||||
"repository": REPOSITORY,
|
||||
"workflow_id": WORKFLOW_ID,
|
||||
"workflow_path": WORKFLOW_PATH,
|
||||
"run_id": RUN_ID,
|
||||
"run_attempt": RUN_ATTEMPT,
|
||||
"source_comment_id": SOURCE_COMMENT_ID,
|
||||
"dispatch_comment_id": DISPATCH_COMMENT_ID,
|
||||
"actor_id": ACTOR_ID,
|
||||
"actor_login": ACTOR_LOGIN,
|
||||
"pr_number": PR_NUMBER,
|
||||
"pr_head_sha": PR_HEAD_SHA,
|
||||
"products": PRODUCTS,
|
||||
"cases_ref": CASES_REF,
|
||||
"source_body_sha256": hashlib.sha256(SOURCE_BODY.encode("utf-8")).hexdigest(),
|
||||
"idempotency_key": f"{REPOSITORY_ID}:{SOURCE_COMMENT_ID}",
|
||||
}
|
||||
assert validate_comment_author(comment) is False
|
||||
|
||||
|
||||
def test_rejects_wrong_bot_login():
|
||||
"""非 github-actions[bot] 的 Bot 账号必须被拒绝。"""
|
||||
comment = {
|
||||
"id": 1235,
|
||||
"user": {"login": "some-other-bot[bot]", "type": "Bot"},
|
||||
"body": '<!-- eval-dispatch: {"pr_number":"899","pr_head_sha":"a"*40,"products":"drive","cases_ref":"","run_id":"99999"} -->',
|
||||
"performed_via_github_app": {"slug": "some-other-app"},
|
||||
archive = _zip_single_file(
|
||||
ARTIFACT_FILENAME,
|
||||
(_compact_json(manifest) + "\n").encode("utf-8"),
|
||||
)
|
||||
artifact_digest = "sha256:" + hashlib.sha256(archive).hexdigest()
|
||||
marker = {
|
||||
"schema_version": 1,
|
||||
"repository_id": REPOSITORY_ID,
|
||||
"workflow_id": WORKFLOW_ID,
|
||||
"workflow_path": WORKFLOW_PATH,
|
||||
"run_id": RUN_ID,
|
||||
"run_attempt": RUN_ATTEMPT,
|
||||
"dispatch_comment_id": DISPATCH_COMMENT_ID,
|
||||
"artifact_id": ARTIFACT_ID,
|
||||
"artifact_digest": artifact_digest,
|
||||
}
|
||||
assert validate_comment_author(comment) is False
|
||||
|
||||
|
||||
def test_rejects_bot_without_app_signature():
|
||||
"""github-actions[bot] 但缺少 performed_via_github_app 签名。"""
|
||||
issue_url = f"https://api.github.com/repos/{REPOSITORY}/issues/{PR_NUMBER}"
|
||||
comment = {
|
||||
"id": 1236,
|
||||
"id": int(DISPATCH_COMMENT_ID),
|
||||
"issue_url": issue_url,
|
||||
"body": f"<!-- eval-dispatch: {_compact_json(marker)} -->\n已受理。",
|
||||
"user": {"login": "github-actions[bot]", "type": "Bot"},
|
||||
"body": '<!-- eval-dispatch: {"pr_number":"899"} -->',
|
||||
"performed_via_github_app": None,
|
||||
}
|
||||
assert validate_comment_author(comment) is False
|
||||
|
||||
|
||||
def test_rejects_bot_with_wrong_app_slug():
|
||||
"""github-actions[bot] 但 App slug 不匹配。"""
|
||||
comment = {
|
||||
"id": 1237,
|
||||
"user": {"login": "github-actions[bot]", "type": "Bot"},
|
||||
"body": '<!-- eval-dispatch: {"pr_number":"899"} -->',
|
||||
"performed_via_github_app": {"slug": "dependabot"},
|
||||
}
|
||||
assert validate_comment_author(comment) is False
|
||||
|
||||
|
||||
def test_accepts_legitimate_github_actions_comment():
|
||||
"""正确的 github-actions[bot] + github-actions App 签名通过身份校验。"""
|
||||
comment = {
|
||||
"id": 1238,
|
||||
"user": {"login": "github-actions[bot]", "type": "Bot"},
|
||||
"body": '<!-- eval-dispatch: {"pr_number":"899","pr_head_sha":"abcd1234","products":"drive","cases_ref":"","run_id":"12345"} -->',
|
||||
"performed_via_github_app": {"slug": "github-actions"},
|
||||
}
|
||||
assert validate_comment_author(comment) is True
|
||||
run = {
|
||||
"id": int(RUN_ID),
|
||||
"run_attempt": int(RUN_ATTEMPT),
|
||||
"workflow_id": int(WORKFLOW_ID),
|
||||
"path": WORKFLOW_PATH,
|
||||
"event": "issue_comment",
|
||||
"head_branch": "main",
|
||||
"head_sha": DEFAULT_BRANCH_SHA,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"repository": {"id": int(REPOSITORY_ID), "full_name": REPOSITORY},
|
||||
"head_repository": {"id": int(REPOSITORY_ID), "full_name": REPOSITORY},
|
||||
"actor": {"id": int(ACTOR_ID), "login": ACTOR_LOGIN},
|
||||
}
|
||||
artifact = {
|
||||
"id": int(ARTIFACT_ID),
|
||||
"name": f"eval-dispatch-request-{RUN_ID}-{RUN_ATTEMPT}-{DISPATCH_COMMENT_ID}",
|
||||
"size_in_bytes": len(archive),
|
||||
"expired": False,
|
||||
"digest": artifact_digest,
|
||||
"workflow_run": {
|
||||
"id": int(RUN_ID),
|
||||
"repository_id": int(REPOSITORY_ID),
|
||||
"head_repository_id": int(REPOSITORY_ID),
|
||||
"head_sha": DEFAULT_BRANCH_SHA,
|
||||
},
|
||||
}
|
||||
source_comment = {
|
||||
"id": int(SOURCE_COMMENT_ID),
|
||||
"issue_url": issue_url,
|
||||
"body": SOURCE_BODY,
|
||||
"user": {"id": int(ACTOR_ID), "login": ACTOR_LOGIN},
|
||||
}
|
||||
pr = {"number": int(PR_NUMBER), "state": "open", "head": {"sha": PR_HEAD_SHA}}
|
||||
client = FakeGitHubClient(
|
||||
run=run,
|
||||
artifact=artifact,
|
||||
archive=archive,
|
||||
source_comment=source_comment,
|
||||
pr=pr,
|
||||
)
|
||||
return comment, client, manifest, marker
|
||||
|
||||
|
||||
def test_extract_payload_valid():
|
||||
body = '<!-- eval-dispatch: {"pr_number":"899","pr_head_sha":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","products":"drive","cases_ref":"","run_id":"123"} -->\nsome text'
|
||||
payload = extract_payload(body)
|
||||
assert payload is not None
|
||||
assert payload["pr_number"] == "899"
|
||||
assert payload["run_id"] == "123"
|
||||
assert payload["pr_head_sha"] == "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||
class EvalPollValidateTests(unittest.TestCase):
|
||||
def assert_rejected(self, comment, client):
|
||||
self.assertIsNone(validate_comment(comment, client=client))
|
||||
|
||||
def test_accepts_fully_bound_dispatch(self):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
|
||||
def test_extract_payload_no_marker():
|
||||
body = "just a regular comment"
|
||||
assert extract_payload(body) is None
|
||||
self.assertEqual(
|
||||
validate_comment(comment, client=client),
|
||||
{
|
||||
"pr_number": PR_NUMBER,
|
||||
"pr_head_sha": PR_HEAD_SHA,
|
||||
"products": PRODUCTS,
|
||||
"cases_ref": CASES_REF,
|
||||
"idempotency_key": f"{REPOSITORY_ID}:{SOURCE_COMMENT_ID}",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
client.calls,
|
||||
[
|
||||
("get_run_attempt", RUN_ID, RUN_ATTEMPT),
|
||||
("get_artifact", ARTIFACT_ID),
|
||||
("download_artifact", ARTIFACT_ID),
|
||||
("get_comment", SOURCE_COMMENT_ID),
|
||||
("get_pull_request", PR_NUMBER),
|
||||
],
|
||||
)
|
||||
|
||||
def test_rejects_untrusted_or_malformed_dispatch_comment(self):
|
||||
mutations = (
|
||||
("非字典", lambda comment: None),
|
||||
("缺少用户", lambda comment: comment.pop("user")),
|
||||
("伪造机器人", lambda comment: comment["user"].update(login="attacker")),
|
||||
("用户类型错误", lambda comment: comment["user"].update(type="User")),
|
||||
("缺少 GitHub App", lambda comment: comment.pop("performed_via_github_app")),
|
||||
(
|
||||
"GitHub App 错误",
|
||||
lambda comment: comment["performed_via_github_app"].update(
|
||||
slug="untrusted-app"
|
||||
),
|
||||
),
|
||||
("body 非字符串", lambda comment: comment.update(body=None)),
|
||||
("comment id 非十进制", lambda comment: comment.update(id="01")),
|
||||
(
|
||||
"重复 marker",
|
||||
lambda comment: comment.update(body=comment["body"] + "\n" + comment["body"]),
|
||||
),
|
||||
)
|
||||
for label, mutate in mutations:
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
replacement = mutate(comment)
|
||||
if label == "非字典":
|
||||
comment = replacement
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_extract_payload_malformed_json():
|
||||
body = "<!-- eval-dispatch: {invalid json} -->"
|
||||
assert extract_payload(body) is None
|
||||
def test_rejects_marker_schema_type_and_binding_drift(self):
|
||||
mutations = (
|
||||
("schema 版本", "schema_version", 2),
|
||||
("schema 类型", "schema_version", "1"),
|
||||
("仓库 ID", "repository_id", "999"),
|
||||
("仓库 ID 类型", "repository_id", int(REPOSITORY_ID)),
|
||||
("workflow ID", "workflow_id", "999"),
|
||||
("workflow path", "workflow_path", ".github/workflows/other.yml"),
|
||||
("run ID 类型", "run_id", int(RUN_ID)),
|
||||
("attempt 前导零", "run_attempt", "01"),
|
||||
("comment ID", "dispatch_comment_id", "999"),
|
||||
("artifact ID 类型", "artifact_id", int(ARTIFACT_ID)),
|
||||
("digest 无前缀", "artifact_digest", "0" * 64),
|
||||
("digest 大写", "artifact_digest", "sha256:" + "A" * 64),
|
||||
)
|
||||
for label, field, value in mutations:
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, marker = valid_fixture()
|
||||
marker[field] = value
|
||||
_set_marker(comment, marker)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
for label, mutate in (
|
||||
("marker 缺字段", lambda marker: marker.pop("artifact_id")),
|
||||
("marker 多字段", lambda marker: marker.update(payload={"products": "all"})),
|
||||
):
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, marker = valid_fixture()
|
||||
mutate(marker)
|
||||
_set_marker(comment, marker)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_extract_payload_rejects_non_dict_integer():
|
||||
"""JSON 整数不是合法 payload。"""
|
||||
body = "<!-- eval-dispatch: 1 -->"
|
||||
assert extract_payload(body) is None
|
||||
def test_rejects_marker_copied_to_different_comment(self):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
comment["id"] = int(DISPATCH_COMMENT_ID) + 1
|
||||
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_extract_payload_rejects_non_dict_array():
|
||||
"""JSON 数组不是合法 payload。"""
|
||||
body = '<!-- eval-dispatch: [1, 2, 3] -->'
|
||||
assert extract_payload(body) is None
|
||||
def test_emits_stable_idempotency_key_across_run_attempts(self):
|
||||
first_comment, first_client, _, _ = valid_fixture()
|
||||
first_result = validate_comment(first_comment, client=first_client)
|
||||
|
||||
second_comment, second_client, manifest, marker = valid_fixture()
|
||||
second_attempt = "2"
|
||||
second_dispatch_comment_id = str(int(DISPATCH_COMMENT_ID) + 1)
|
||||
second_artifact_id = str(int(ARTIFACT_ID) + 1)
|
||||
second_comment["id"] = int(second_dispatch_comment_id)
|
||||
marker.update(
|
||||
run_attempt=second_attempt,
|
||||
dispatch_comment_id=second_dispatch_comment_id,
|
||||
artifact_id=second_artifact_id,
|
||||
)
|
||||
manifest.update(
|
||||
run_attempt=second_attempt,
|
||||
dispatch_comment_id=second_dispatch_comment_id,
|
||||
)
|
||||
second_client.run["run_attempt"] = int(second_attempt)
|
||||
second_client.artifact.update(
|
||||
id=int(second_artifact_id),
|
||||
name=(
|
||||
f"eval-dispatch-request-{RUN_ID}-{second_attempt}-"
|
||||
f"{second_dispatch_comment_id}"
|
||||
),
|
||||
)
|
||||
_install_manifest(second_comment, second_client, manifest, marker)
|
||||
|
||||
def test_extract_payload_rejects_non_dict_string():
|
||||
"""JSON 字符串不是合法 payload。"""
|
||||
body = '<!-- eval-dispatch: "hello" -->'
|
||||
assert extract_payload(body) is None
|
||||
second_result = validate_comment(second_comment, client=second_client)
|
||||
|
||||
self.assertIsNotNone(first_result)
|
||||
self.assertIsNotNone(second_result)
|
||||
self.assertEqual(
|
||||
first_result["idempotency_key"],
|
||||
second_result["idempotency_key"],
|
||||
)
|
||||
|
||||
def test_extract_payload_rejects_non_dict_null():
|
||||
"""JSON null 不是合法 payload。"""
|
||||
body = "<!-- eval-dispatch: null -->"
|
||||
assert extract_payload(body) is None
|
||||
def test_rejects_cross_workflow_and_run_attempt_drift(self):
|
||||
mutations = (
|
||||
("run id", ("id",), int(RUN_ID) + 1),
|
||||
("run attempt", ("run_attempt",), int(RUN_ATTEMPT) + 1),
|
||||
("workflow id", ("workflow_id",), int(WORKFLOW_ID) + 1),
|
||||
("workflow path", ("path",), ".github/workflows/other.yml"),
|
||||
("event", ("event",), "workflow_dispatch"),
|
||||
("branch", ("head_branch",), "feature/eval"),
|
||||
("status", ("status",), "in_progress"),
|
||||
("conclusion", ("conclusion",), "failure"),
|
||||
("repository id", ("repository", "id"), int(REPOSITORY_ID) + 1),
|
||||
("repository name", ("repository", "full_name"), "attacker/fork"),
|
||||
(
|
||||
"head repository id",
|
||||
("head_repository", "id"),
|
||||
int(REPOSITORY_ID) + 1,
|
||||
),
|
||||
(
|
||||
"head repository name",
|
||||
("head_repository", "full_name"),
|
||||
"attacker/fork",
|
||||
),
|
||||
("head sha 类型", ("head_sha",), None),
|
||||
)
|
||||
for label, path, value in mutations:
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
_set_nested(client.run, path, value)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_artifact_metadata_drift(self):
|
||||
mutations = (
|
||||
("artifact id", ("id",), int(ARTIFACT_ID) + 1),
|
||||
("artifact name", ("name",), "eval-dispatch-request-forged"),
|
||||
("expired", ("expired",), True),
|
||||
("expired 类型", ("expired",), 0),
|
||||
("digest", ("digest",), "sha256:" + "0" * 64),
|
||||
("size 零", ("size_in_bytes",), 0),
|
||||
("size 类型", ("size_in_bytes",), "123"),
|
||||
(
|
||||
"workflow run id",
|
||||
("workflow_run", "id"),
|
||||
int(RUN_ID) + 1,
|
||||
),
|
||||
(
|
||||
"workflow repository id",
|
||||
("workflow_run", "repository_id"),
|
||||
int(REPOSITORY_ID) + 1,
|
||||
),
|
||||
(
|
||||
"workflow head repository id",
|
||||
("workflow_run", "head_repository_id"),
|
||||
int(REPOSITORY_ID) + 1,
|
||||
),
|
||||
("workflow head sha", ("workflow_run", "head_sha"), "c" * 40),
|
||||
)
|
||||
for label, path, value in mutations:
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
_set_nested(client.artifact, path, value)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_extract_payload_rejects_numeric_run_id():
|
||||
"""run_id 为数值类型时拒绝。"""
|
||||
body = '<!-- eval-dispatch: {"pr_number":"899","pr_head_sha":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","products":"drive","cases_ref":"","run_id":12345} -->'
|
||||
assert extract_payload(body) is None
|
||||
comment, client, _, _ = valid_fixture()
|
||||
client.artifact["workflow_run"] = None
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_download_digest_mismatch(self):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
client.archive += b"tampered-after-digest"
|
||||
|
||||
def test_extract_payload_rejects_numeric_pr_number():
|
||||
"""pr_number 为数值类型时拒绝。"""
|
||||
body = '<!-- eval-dispatch: {"pr_number":899,"pr_head_sha":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","products":"drive","cases_ref":"","run_id":"123"} -->'
|
||||
assert extract_payload(body) is None
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_tampered_manifest_payload(self):
|
||||
for field, value in (
|
||||
("products", "sheet"),
|
||||
("cases_ref", "fixtures/other"),
|
||||
("pr_head_sha", "c" * 40),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
manifest[field] = value
|
||||
_install_manifest(comment, client, manifest, marker)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_extract_payload_rejects_invalid_sha_format():
|
||||
"""SHA 非 40 位十六进制时拒绝。"""
|
||||
body = '<!-- eval-dispatch: {"pr_number":"899","pr_head_sha":"short","products":"drive","cases_ref":"","run_id":"123"} -->'
|
||||
assert extract_payload(body) is None
|
||||
def test_rejects_cross_workflow_historical_run_reuse(self):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
historical_run_id = str(int(RUN_ID) - 100)
|
||||
marker["run_id"] = historical_run_id
|
||||
manifest["run_id"] = historical_run_id
|
||||
client.run["id"] = int(historical_run_id)
|
||||
client.run["workflow_id"] = int(WORKFLOW_ID) + 1
|
||||
client.artifact["name"] = (
|
||||
f"eval-dispatch-request-{historical_run_id}-{RUN_ATTEMPT}-"
|
||||
f"{DISPATCH_COMMENT_ID}"
|
||||
)
|
||||
client.artifact["workflow_run"]["id"] = int(historical_run_id)
|
||||
_install_manifest(comment, client, manifest, marker)
|
||||
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_extract_payload_rejects_invalid_products():
|
||||
"""products 含非法字符时拒绝。"""
|
||||
body = '<!-- eval-dispatch: {"pr_number":"899","pr_head_sha":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","products":"drive; rm -rf /","cases_ref":"","run_id":"123"} -->'
|
||||
assert extract_payload(body) is None
|
||||
def test_rejects_unsafe_or_ambiguous_zip(self):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
manifest_bytes = (_compact_json(manifest) + "\n").encode("utf-8")
|
||||
unsafe_archives = (
|
||||
("非 ZIP", b"not-a-zip"),
|
||||
(
|
||||
"路径穿越",
|
||||
_zip_single_file("../eval-dispatch-request.json", manifest_bytes),
|
||||
),
|
||||
(
|
||||
"绝对路径",
|
||||
_zip_single_file("/eval-dispatch-request.json", manifest_bytes),
|
||||
),
|
||||
(
|
||||
"多文件",
|
||||
_zip_files(
|
||||
(
|
||||
(ARTIFACT_FILENAME, manifest_bytes),
|
||||
("second.json", b"{}"),
|
||||
)
|
||||
),
|
||||
),
|
||||
(
|
||||
"重复文件名",
|
||||
_zip_files(
|
||||
(
|
||||
(ARTIFACT_FILENAME, manifest_bytes),
|
||||
(ARTIFACT_FILENAME, manifest_bytes),
|
||||
)
|
||||
),
|
||||
),
|
||||
("符号链接", _zip_symlink(ARTIFACT_FILENAME, "target.json")),
|
||||
(
|
||||
"不允许的压缩算法",
|
||||
_zip_files(
|
||||
((ARTIFACT_FILENAME, manifest_bytes),),
|
||||
compression=zipfile.ZIP_BZIP2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"解压后 manifest 超限",
|
||||
_zip_single_file(ARTIFACT_FILENAME, b"x" * (64 * 1024 + 1)),
|
||||
),
|
||||
(
|
||||
"压缩包字节超限",
|
||||
_zip_files(
|
||||
((ARTIFACT_FILENAME, b"x" * (64 * 1024)),),
|
||||
compression=zipfile.ZIP_STORED,
|
||||
),
|
||||
),
|
||||
)
|
||||
for label, archive in unsafe_archives:
|
||||
with self.subTest(label=label):
|
||||
test_comment, test_client, _, test_marker = valid_fixture()
|
||||
_install_archive(test_comment, test_client, test_marker, archive)
|
||||
self.assert_rejected(test_comment, test_client)
|
||||
|
||||
def test_rejects_duplicate_manifest_json_keys(self):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
raw_manifest = (
|
||||
_compact_json(manifest)[:-1] + ',"products":"sheet"}\n'
|
||||
).encode("utf-8")
|
||||
archive = _zip_single_file(ARTIFACT_FILENAME, raw_manifest)
|
||||
_install_archive(comment, client, marker, archive)
|
||||
|
||||
def test_extract_payload_rejects_missing_required_field():
|
||||
"""缺少必要字段时拒绝。"""
|
||||
body = '<!-- eval-dispatch: {"pr_number":"899","products":"drive","run_id":"123"} -->'
|
||||
assert extract_payload(body) is None
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_duplicate_marker_json_keys(self):
|
||||
comment, client, _, marker = valid_fixture()
|
||||
raw_marker = _compact_json(marker)[:-1] + ',"run_id":"1"}'
|
||||
comment["body"] = f"<!-- eval-dispatch: {raw_marker} -->\n已受理。"
|
||||
|
||||
def test_validate_run_id_rejects_non_string():
|
||||
"""run_id 为非字符串类型时拒绝。"""
|
||||
assert validate_run_id(12345) is False
|
||||
assert validate_run_id(None) is False
|
||||
assert validate_run_id(["123"]) is False
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_manifest_exact_field_and_type_violations(self):
|
||||
mutations = (
|
||||
("schema 版本", lambda manifest: manifest.update(schema_version=2)),
|
||||
("schema bool", lambda manifest: manifest.update(schema_version=True)),
|
||||
(
|
||||
"repository ID 类型",
|
||||
lambda manifest: manifest.update(repository_id=int(REPOSITORY_ID)),
|
||||
),
|
||||
(
|
||||
"repository name",
|
||||
lambda manifest: manifest.update(repository="attacker/fork"),
|
||||
),
|
||||
(
|
||||
"workflow ID 类型",
|
||||
lambda manifest: manifest.update(workflow_id=int(WORKFLOW_ID)),
|
||||
),
|
||||
(
|
||||
"workflow path",
|
||||
lambda manifest: manifest.update(
|
||||
workflow_path=".github/workflows/other.yml"
|
||||
),
|
||||
),
|
||||
("run ID 类型", lambda manifest: manifest.update(run_id=int(RUN_ID))),
|
||||
("attempt 前导零", lambda manifest: manifest.update(run_attempt="01")),
|
||||
("source comment 零", lambda manifest: manifest.update(source_comment_id="0")),
|
||||
(
|
||||
"dispatch comment 超长",
|
||||
lambda manifest: manifest.update(dispatch_comment_id="1" * 21),
|
||||
),
|
||||
("actor ID 类型", lambda manifest: manifest.update(actor_id=int(ACTOR_ID))),
|
||||
("actor login", lambda manifest: manifest.update(actor_login="bad_login")),
|
||||
("actor login 超长", lambda manifest: manifest.update(actor_login="a" * 40)),
|
||||
("PR number 类型", lambda manifest: manifest.update(pr_number=int(PR_NUMBER))),
|
||||
("PR SHA 大写", lambda manifest: manifest.update(pr_head_sha="A" * 40)),
|
||||
("products 空", lambda manifest: manifest.update(products="")),
|
||||
("products 非法", lambda manifest: manifest.update(products="drive,Doc")),
|
||||
("products 超长", lambda manifest: manifest.update(products="a" * 513)),
|
||||
(
|
||||
"source hash 带前缀",
|
||||
lambda manifest: manifest.update(
|
||||
source_body_sha256="sha256:" + "0" * 64
|
||||
),
|
||||
),
|
||||
(
|
||||
"idempotency 前缀错误",
|
||||
lambda manifest: manifest.update(
|
||||
idempotency_key=(
|
||||
f"eval-dispatch:{REPOSITORY_ID}:{SOURCE_COMMENT_ID}"
|
||||
)
|
||||
),
|
||||
),
|
||||
("缺字段", lambda manifest: manifest.pop("products")),
|
||||
("多字段", lambda manifest: manifest.update(payload={})),
|
||||
)
|
||||
for label, mutate in mutations:
|
||||
with self.subTest(label=label):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
mutate(manifest)
|
||||
_install_manifest(comment, client, manifest, marker)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_validate_run_id_rejects_non_digit_string():
|
||||
"""run_id 含非数字字符时拒绝。"""
|
||||
assert validate_run_id("abc") is False
|
||||
assert validate_run_id("123abc") is False
|
||||
assert validate_run_id("") is False
|
||||
def test_rejects_invalid_cases_refs(self):
|
||||
invalid_refs = (
|
||||
"-option",
|
||||
"/absolute",
|
||||
"trailing/",
|
||||
"trailing.",
|
||||
"a..b",
|
||||
"a//b",
|
||||
".hidden",
|
||||
"a/.hidden",
|
||||
"a.lock",
|
||||
"a/b.lock",
|
||||
"contains space",
|
||||
"中文",
|
||||
"a" * 1025,
|
||||
)
|
||||
for cases_ref in invalid_refs:
|
||||
with self.subTest(cases_ref=cases_ref[:40]):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
manifest["cases_ref"] = cases_ref
|
||||
_install_manifest(comment, client, manifest, marker)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_accepts_empty_cases_ref(self):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
source_body = f"/eval {PRODUCTS} sha={PR_HEAD_SHA}"
|
||||
manifest["cases_ref"] = ""
|
||||
manifest["source_body_sha256"] = hashlib.sha256(
|
||||
source_body.encode("utf-8")
|
||||
).hexdigest()
|
||||
client.source_comment["body"] = source_body
|
||||
_install_manifest(comment, client, manifest, marker)
|
||||
|
||||
result = validate_comment(comment, client=client)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["cases_ref"], "")
|
||||
|
||||
def test_rejects_source_comment_and_actor_drift(self):
|
||||
mutations = (
|
||||
("source id", ("source_comment", "id"), int(SOURCE_COMMENT_ID) + 1),
|
||||
(
|
||||
"source issue URL",
|
||||
("source_comment", "issue_url"),
|
||||
f"https://api.github.com/repos/{REPOSITORY}/issues/951",
|
||||
),
|
||||
("source user", ("source_comment", "user"), None),
|
||||
(
|
||||
"source actor id",
|
||||
("source_comment", "user", "id"),
|
||||
int(ACTOR_ID) + 1,
|
||||
),
|
||||
(
|
||||
"source actor login",
|
||||
("source_comment", "user", "login"),
|
||||
"attacker",
|
||||
),
|
||||
("source body 类型", ("source_comment", "body"), None),
|
||||
("source body hash", ("source_comment", "body"), SOURCE_BODY + "\nchanged"),
|
||||
("run actor", ("run", "actor"), None),
|
||||
("run actor id", ("run", "actor", "id"), int(ACTOR_ID) + 1),
|
||||
("run actor login", ("run", "actor", "login"), "attacker"),
|
||||
)
|
||||
for label, path, value in mutations:
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
target = client.source_comment if path[0] == "source_comment" else client.run
|
||||
_set_nested(target, path[1:], value)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_source_command_payload_mismatch_even_with_matching_hash(self):
|
||||
comment, client, manifest, marker = valid_fixture()
|
||||
changed_body = f"/eval sheet sha={PR_HEAD_SHA} cases={CASES_REF}"
|
||||
client.source_comment["body"] = changed_body
|
||||
manifest["source_body_sha256"] = hashlib.sha256(
|
||||
changed_body.encode("utf-8")
|
||||
).hexdigest()
|
||||
_install_manifest(comment, client, manifest, marker)
|
||||
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_dispatch_issue_url_drift(self):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
comment["issue_url"] = f"https://api.github.com/repos/{REPOSITORY}/issues/951"
|
||||
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_rejects_pull_request_drift(self):
|
||||
mutations = (
|
||||
("PR number", ("number",), int(PR_NUMBER) + 1),
|
||||
("PR closed", ("state",), "closed"),
|
||||
("PR head", ("head", "sha"), "c" * 40),
|
||||
("PR head 缺失", ("head",), None),
|
||||
)
|
||||
for label, path, value in mutations:
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
_set_nested(client.pr, path, value)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_api_errors_and_invalid_response_shapes_fail_closed(self):
|
||||
def explode(*_args, **_kwargs):
|
||||
raise RuntimeError("simulated API failure")
|
||||
|
||||
for method_name in (
|
||||
"get_run_attempt",
|
||||
"get_artifact",
|
||||
"download_artifact",
|
||||
"get_comment",
|
||||
"get_pull_request",
|
||||
):
|
||||
with self.subTest(method=method_name):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
setattr(client, method_name, explode)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
for label, attribute, value in (
|
||||
("run list", "run", []),
|
||||
("artifact null", "artifact", None),
|
||||
("archive text", "archive", "not bytes"),
|
||||
("source list", "source_comment", []),
|
||||
("PR list", "pr", []),
|
||||
):
|
||||
with self.subTest(label=label):
|
||||
comment, client, _, _ = valid_fixture()
|
||||
setattr(client, attribute, value)
|
||||
self.assert_rejected(comment, client)
|
||||
|
||||
def test_cli_client_uses_attempt_and_artifact_id_endpoints(self):
|
||||
client = GitHubCLIClient()
|
||||
with mock.patch("eval_poll_validate.subprocess.run") as run:
|
||||
run.return_value = mock.Mock(returncode=0, stdout="{}")
|
||||
client.get_run_attempt(RUN_ID, RUN_ATTEMPT)
|
||||
run.assert_called_once_with(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
(
|
||||
f"repos/{REPOSITORY}/actions/runs/{RUN_ID}/"
|
||||
f"attempts/{RUN_ATTEMPT}"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
with mock.patch("eval_poll_validate.subprocess.run") as run:
|
||||
run.return_value = mock.Mock(returncode=0, stdout=b"zip")
|
||||
self.assertEqual(client.download_artifact(ARTIFACT_ID), b"zip")
|
||||
run.assert_called_once_with(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
f"repos/{REPOSITORY}/actions/artifacts/{ARTIFACT_ID}/zip",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def test_main_rejects_duplicate_stdin_json_keys_without_network(self):
|
||||
with mock.patch(
|
||||
"eval_poll_validate.sys.stdin",
|
||||
io.StringIO('{"body":{},"body":{}}'),
|
||||
), mock.patch("eval_poll_validate.subprocess.run") as run:
|
||||
self.assertEqual(validate_main(), 1)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_rejects_regular_user_comment()
|
||||
test_rejects_wrong_bot_login()
|
||||
test_rejects_bot_without_app_signature()
|
||||
test_rejects_bot_with_wrong_app_slug()
|
||||
test_accepts_legitimate_github_actions_comment()
|
||||
test_extract_payload_valid()
|
||||
test_extract_payload_no_marker()
|
||||
test_extract_payload_malformed_json()
|
||||
test_extract_payload_rejects_non_dict_integer()
|
||||
test_extract_payload_rejects_non_dict_array()
|
||||
test_extract_payload_rejects_non_dict_string()
|
||||
test_extract_payload_rejects_non_dict_null()
|
||||
test_extract_payload_rejects_numeric_run_id()
|
||||
test_extract_payload_rejects_numeric_pr_number()
|
||||
test_extract_payload_rejects_invalid_sha_format()
|
||||
test_extract_payload_rejects_invalid_products()
|
||||
test_extract_payload_rejects_missing_required_field()
|
||||
test_validate_run_id_rejects_non_string()
|
||||
test_validate_run_id_rejects_non_digit_string()
|
||||
print("All eval_poll_validate tests passed.")
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -31,8 +31,6 @@ func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T
|
||||
"EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt",
|
||||
"REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}",
|
||||
"eval_dispatch_guard.py head",
|
||||
"RUN_ID: ${{ github.run_id }}",
|
||||
"eval_poll_validate.py",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("eval-dispatch workflow missing security contract %q", want)
|
||||
@@ -46,6 +44,101 @@ func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "eval-dispatch.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read eval-dispatch workflow: %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
orderedSteps := []string{
|
||||
"- name: Create dispatch placeholder",
|
||||
"- name: Build dispatch request manifest",
|
||||
"- name: Upload dispatch request manifest",
|
||||
"- name: Finalize dispatch marker",
|
||||
}
|
||||
previous := -1
|
||||
for _, step := range orderedSteps {
|
||||
index := strings.Index(workflow, step)
|
||||
if index < 0 {
|
||||
t.Fatalf("eval-dispatch workflow missing step %q", step)
|
||||
}
|
||||
if index <= previous {
|
||||
t.Fatalf("eval-dispatch workflow step %q is out of order", step)
|
||||
}
|
||||
previous = index
|
||||
}
|
||||
|
||||
placeholderStart := strings.Index(workflow, orderedSteps[0])
|
||||
manifestStart := strings.Index(workflow, orderedSteps[1])
|
||||
if strings.Contains(workflow[placeholderStart:manifestStart], "<!-- eval-dispatch:") {
|
||||
t.Fatal("dispatch placeholder must not expose a consumable marker before the manifest exists")
|
||||
}
|
||||
if count := strings.Count(workflow, "<!-- eval-dispatch:"); count != 1 {
|
||||
t.Fatalf("eval-dispatch workflow marker count = %d, want exactly one finalized marker", count)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"REPOSITORY_ID: '1187709537'",
|
||||
"WORKFLOW_ID: '331725458'",
|
||||
"WORKFLOW_PATH: .github/workflows/eval-dispatch.yml",
|
||||
"RUN_ID: ${{ github.run_id }}",
|
||||
"RUN_ATTEMPT: ${{ github.run_attempt }}",
|
||||
"SOURCE_COMMENT_ID: ${{ github.event.comment.id }}",
|
||||
"DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}",
|
||||
"idempotency_key: $idempotency_key",
|
||||
"actions/upload-artifact@v4",
|
||||
"name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}",
|
||||
"path: ${{ runner.temp }}/eval-dispatch-request.json",
|
||||
"if-no-files-found: error",
|
||||
"retention-days: 1",
|
||||
"overwrite: false",
|
||||
"ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}",
|
||||
"ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}",
|
||||
"<!-- eval-dispatch: ${marker_json} -->",
|
||||
"-X PATCH",
|
||||
"issues/comments/${DISPATCH_COMMENT_ID}",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("eval-dispatch workflow missing artifact contract %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forbidden := range []string{
|
||||
"EVAL_TRIGGER_URL",
|
||||
"EVAL_TRIGGER_TOKEN",
|
||||
} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("eval-dispatch workflow exposes retired direct-trigger detail %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalPollValidatePython(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
cmd := exec.Command(
|
||||
"python3",
|
||||
"-B",
|
||||
filepath.Join(root, "scripts", "ci", "test_eval_poll_validate.py"),
|
||||
)
|
||||
cmd.Dir = root
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("eval poll validator tests failed: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDispatchRejectsLowRepositoryPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user