From 39834ead7d5cae48477621b5e68f737ae0082e65 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 17:28:55 +0000 Subject: [PATCH] agent-exec transport 3cbfba3aed64b516704b0d68a7a9f2fa/82da3fb3f31d --- policy/facts.md | 2 + scripts/facts-verify.sh | 21 +++++++-- tests/test_facts_verify.py | 95 ++++++++++++++++++++++++++++++-------- 3 files changed, 94 insertions(+), 24 deletions(-) diff --git a/policy/facts.md b/policy/facts.md index 24953f0e..3fcf83fa 100644 --- a/policy/facts.md +++ b/policy/facts.md @@ -10,6 +10,8 @@ - 凭据类验证命令不得把凭据展开进命令行、输出或证据:经 `scripts/facts-verify.sh` 探针子命令在进程内读取并发送。 - 旧端点 git-panel.u1680832.nyat.app:30319 已退役,一律不得使用——这是政策禁令,不随 F4 核验结果变化; F4 只登记可观测的 TLS 握手失败现象,DNS 失败、超时、连接失败或握手成功都判 FAIL,不刷新 last_verified。 +- F4 现有 last_verified(2026-10-14)是历史日期,来自旧的宽泛检查,不证明当前 TLS 层握手失败判据; + 经该判据独立重验前不得当作已核实。 | ID | 事实 | 验证命令 | last_verified | TTL(天) | |---|---|---|---|---| diff --git a/scripts/facts-verify.sh b/scripts/facts-verify.sh index ccbbafe4..61d4200c 100755 --- a/scripts/facts-verify.sh +++ b/scripts/facts-verify.sh @@ -4,8 +4,9 @@ # 用法:make facts-verify [ARGS=--update] (--update:把 PASS 项 last_verified 刷为当天并写回) # 表内验证命令约定不含竖线;含 "\|" 转义时按字面还原后执行。 # 登记表引用的探针子命令(单独运行时在 stderr 说明判定,不输出凭据): -# api-token URL 进程内读 ~/.secrets/gitea HOME 段 ACCESS_TOKEN 并 GET URL; -# 0=接受 3=缺失 4=格式不符 5=请求失败/被拒 +# api-token URL URL 须逐字等于登记端点 https://git-home.u1680832.nyat.app:30319/api/v1/user, +# 校验通过后才进程内读 ~/.secrets/gitea HOME 段 ACCESS_TOKEN 并 GET(不跟随重定向); +# 0=接受 3=缺失 4=格式不符 5=请求失败/被拒/重定向 6=非登记端点 # tls-handshake-fails HOST PORT 仅当 TCP 已连通且 TLS 层握手报错时 0; # 3=DNS 失败 4=超时 5=连接失败 6=握手成功(端点仍可用) set -uo pipefail @@ -15,11 +16,23 @@ case "${1:-}" in api-token) [ $# -eq 2 ] || { echo "usage: facts-verify.sh api-token URL" >&2; exit 2; } exec python3 - "$SCRIPT_DIR" "$2" <<'PY' -import sys +import sys, urllib.request sys.path.insert(0, sys.argv[1]) import task_triage -url = sys.argv[2] +ENDPOINT = "https://git-home.u1680832.nyat.app:30319/api/v1/user" +if sys.argv[2] != ENDPOINT: + print("api-token: URL 不是 policy/facts.md F2 登记端点,拒绝发送凭据", file=sys.stderr) + sys.exit(6) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +urllib.request.install_opener(urllib.request.build_opener(NoRedirect)) +url = ENDPOINT token = task_triage.secrets_file_token() if not token: print("api-token: ~/.secrets/gitea HOME 段 ACCESS_TOKEN 缺失", file=sys.stderr) diff --git a/tests/test_facts_verify.py b/tests/test_facts_verify.py index 1883c1da..5b3d9d2a 100644 --- a/tests/test_facts_verify.py +++ b/tests/test_facts_verify.py @@ -1,6 +1,7 @@ """外部事实重验契约(scripts/facts-verify.sh + policy/facts.md 的 F2/F4 验证命令)。 -全部离线:合成 HOME/IMC 凭据、sitecustomize 桩替换 urllib/socket/ssl(真实连接一律拒绝)、 +全部离线:合成 HOME/IMC 凭据、sitecustomize 桩拦截 urllib 处理器网络边界(保留真实重定向判定) +与 socket/ssl(真实连接一律拒绝)、 PATH 桩记录 curl/wget argv、临时 Git 仓库与临时登记表;不读取真实 ~/.secrets,不连接 Gitea 或旧端点。 """ import datetime @@ -22,7 +23,7 @@ OLD_DATE = "2026-01-01" SECRETS = [HOME_TOKEN, IMC_TOKEN] STUB = r''' -import json, os, socket, ssl, subprocess, urllib.error, urllib.request +import http.client, io, json, os, socket, ssl, subprocess, urllib.request, urllib.response def _log(kind, **data): @@ -49,33 +50,38 @@ def _popen(self, args, *rest, **kwargs): subprocess.Popen.__init__ = _popen -class _Response: - def __init__(self, body): - self._body = body - - def read(self): - return self._body - - def __enter__(self): - return self - - def __exit__(self, *exc): - return False +def _response(url, code, body=b"", location=None): + headers = http.client.HTTPMessage() + if location: + headers["Location"] = location + resp = urllib.response.addinfourl(io.BytesIO(body), headers, url, code) + resp.msg = "stub" + return resp -def _urlopen(request, *args, **kwargs): - url = getattr(request, "full_url", request) - auth = request.get_header("Authorization") if hasattr(request, "get_header") else None +REDIRECT_TARGET = "redirect-target.invalid" + + +# Intercept only the handlers' network boundary so the opener's real redirect and +# error processing still decides whether a follow-up request is made. +def _open(self, request): + url = request.full_url + auth = request.get_header("Authorization") _log("http", url=url, authorization=auth) + if REDIRECT_TARGET in url: + return _response(url, 200, b'{"login": "redirect-target"}') mode = os.environ.get("FAKE_HTTP", "200") if mode == "200": - return _Response(b'{"login": "fixture"}') + return _response(url, 200, b'{"login": "fixture"}') if mode == "leak": raise ValueError("Invalid header value " + repr(auth)) - raise urllib.error.HTTPError(url, int(mode), "stub", {}, None) + code = int(mode) + location = "https://" + REDIRECT_TARGET + "/collect" if 300 <= code < 400 else None + return _response(url, code, location=location) -urllib.request.urlopen = _urlopen +urllib.request.HTTPSHandler.https_open = _open +urllib.request.HTTPHandler.http_open = _open class _Conn: @@ -299,6 +305,55 @@ class FactsVerifyTests(unittest.TestCase): self.assertNotIn("Authorization", direct.stderr) self.assert_no_secrets(result.stdout, result.stderr, direct.stdout, direct.stderr) + def test_f2_rejects_unregistered_destination_before_reading_credentials(self): + bad_urls = [ + "http://git-home.u1680832.nyat.app:30319/api/v1/user", + "https://evil.invalid/api/v1/user", + "https://git-home.u1680832.nyat.app:30320/api/v1/user", + "https://git-home.u1680832.nyat.app/api/v1/user", + "https://user:pw@git-home.u1680832.nyat.app:30319/api/v1/user", + "https://git-home.u1680832.nyat.app:30319@evil.invalid/api/v1/user", + "https://git-home.u1680832.nyat.app:30319/api/v1/user#frag", + "https://git-home.u1680832.nyat.app:30319/api/v1/user?next=https://evil.invalid", + "https://git-home.u1680832.nyat.app:30319/api/v1/users", + "https://git-home.u1680832.nyat.app:30319/api/v1/user/../admin", + "https://git-home.u1680832.nyat.app:30319/api/v1/user ", + ] + for secrets_present in (True, False): + if not secrets_present: + (self.home / ".secrets" / "gitea").unlink() + for url in bad_urls: + with self.subTest(url=url, secrets_present=secrets_present): + self.stub_log.unlink(missing_ok=True) + direct = self.run_script("api-token", url) + self.assertEqual(direct.returncode, 6, direct.stderr) + self.assertEqual(self.stub_events("http"), [], "no request to an unregistered destination") + self.assertNotIn(url.strip(), direct.stdout + direct.stderr) + self.assertNotIn("evil.invalid", direct.stdout + direct.stderr) + self.assertNotIn("ACCESS_TOKEN", direct.stderr, "destination is checked before credentials") + self.assert_no_secrets(direct.stdout, direct.stderr) + + def test_f2_refuses_redirect_without_forwarding_authorization(self): + for code in ("301", "302", "303", "307", "308"): + with self.subTest(code): + self.stub_log.unlink(missing_ok=True) + direct = self.run_script("api-token", "https://git-home.u1680832.nyat.app:30319/api/v1/user", + http=code) + self.assertEqual(direct.returncode, 5, direct.stderr) + requests = self.stub_events("http") + self.assertEqual([r["url"] for r in requests], + ["https://git-home.u1680832.nyat.app:30319/api/v1/user"], "no follow-up request") + self.assertNotIn("redirect-target", direct.stdout + direct.stderr) + self.assert_no_secrets(direct.stdout, direct.stderr) + + self.stub_log.unlink(missing_ok=True) + self.write_registry("F2") + result = self.run_script("--update", http=code) + self.assert_status(result.stdout, "F2", "FAIL") + self.assertEqual(self.dates()["F2"], OLD_DATE) + self.assertEqual(len(self.stub_events("http")), 1) + self.assert_no_secrets(result.stdout, result.stderr) + # --- F4:只有 TLS 层握手失败才支持该事实 ----------------------------------- def test_f4_only_tls_handshake_failure_passes_and_refreshes(self):