hermes-hub/scripts/release_gate.py
ochenstarik-ui eac8352dc2 fix(release,web): ворота публикации перестали пропускать всё подряд, /api/action — межсайтовые запросы
P0 аудита, каждый сначала подтверждён исполнением, а не принят со слов.

1. Release Gate объявлял проверку хеша, которой не было. Печаталась строка
   PACKAGE_HASH_VERIFIED=True при том, что hashlib в scripts/release_gate.py
   не вызывался ни разу: скачивались байты 0-10 через заголовок Range, и
   этого хватало, чтобы счесть хеш проверенным. «Проверенным ассетом» при
   этом оказывался первый в списке — checksums.txt, а не пакет.

2. Ворота публикации были fail-open. Измерено в трёх условиях: полный обрыв
   сети -> PASS, манифест 404 -> PASS, пакет 404 -> PASS. Ворота пропускали
   релиз при любом исходе, включая полное отсутствие релиза.

   Разделено на офлайновую часть (проверки 1-7: версии, тесты, updater,
   статика, секреты, список разрешённых адресов) и Publication Gate: релиз
   есть, ассеты есть, пакет скачан ЦЕЛИКОМ, SHA-256 сошёлся с опубликованным
   checksums.txt. Публикационные ворота блокируют в режиме публикации
   (--publication или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне
   CI, где релиза для ветки нет и быть не должно, результат сообщается как
   есть и не блокирует. Неизмеренное называется причиной, а не выдаётся за
   проверенное. Проверено на живом релизе v0.1.3-b1: два пакета скачаны
   целиком, хеши сошлись.

3. POST /api/action на loopback принимал межсайтовые запросы. Токен там не
   требуется, а действие меняет состояние: удаляет учётные данные, чистит
   аккаунты, переключает маршрутизацию, запускает входы OAuth. CORS от этого
   не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено
   на конфигурации по умолчанию: POST с Content-Type text/plain уходит
   кросс-сайтом без предварительного запроса, request.json() разбирает тело
   независимо от Content-Type, и запрос с Origin чужого сайта без токена
   доходил до исполнителя действий.

   Проверяется Sec-Fetch-Site, при его отсутствии — Origin против адреса
   запроса. Собственный интерфейс, адресная строка и не-браузерные клиенты
   работают как раньше. Защита распространена на все пять небезопасных
   методов, не только на /api/action.

4. pricing fallback: safe_load вместо safe_dump. dump сериализовал текст
   обратно в строку, проверка isinstance(data, dict) не выполнялась никогда,
   таблица цен не загружалась ни разу, а except это глушил.

5. Симуляция Linux в тесте stop_running_hub падала на Windows: os.getuid там
   не существует.

Тесты: 756 -> 776 passed, 2 skipped, 4 deselected. ruff check . чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:01:30 +07:00

415 lines
19 KiB
Python
Raw 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.

"""Hermes Hub — Automated Release Gate & Verification Engine.
Strictly checks all criteria before allowing a release build:
1. Version consistency across manifests and code (0.1.1).
2. P0 Release Gate tests pass 100%.
3. Full offline test suite passes hermetically.
4. Auto-updater, cryptographic verification, and rollback pass.
5. Zero hardcoded developer paths (E:\\Agent projects, C:\\Users\\trush, etc.) in src/.
6. Zero secrets / keys / credentials in git repo.
7. Multi-Provider Router verification passes.
"""
from __future__ import annotations
import ast
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT / "src") not in sys.path:
sys.path.insert(0, str(ROOT / "src"))
from antigravity_provider.version import __version__, get_version
from antigravity_provider import paths
def check_version_consistency() -> tuple[bool, str]:
ver = get_version()
# Check compatibility.json
compat_file = ROOT / "config" / "compatibility.json"
if compat_file.exists():
compat_data = json.loads(compat_file.read_text(encoding="utf-8"))
if compat_data.get("hub_version") != ver:
return False, f"compatibility.json has hub_version '{compat_data.get('hub_version')}' != '{ver}'"
# Check pyproject.toml
pyproject_file = ROOT / "pyproject.toml"
if pyproject_file.exists():
content = pyproject_file.read_text(encoding="utf-8")
if f'version = "{ver}"' not in content:
return False, f"pyproject.toml missing version = \"{ver}\""
return True, f"Version {ver} is consistent across all manifests"
def _run_pytest(args: list[str]) -> subprocess.CompletedProcess:
import shutil
env = dict(os.environ)
env["PYTHONPATH"] = str(ROOT / "src")
pytest_bin = shutil.which("pytest")
if pytest_bin:
cmd = [pytest_bin] + args
else:
cmd = [sys.executable, "-m", "pytest"] + args
return subprocess.run(
cmd,
cwd=str(ROOT),
env=env,
capture_output=True,
text=True,
)
def check_p0_release_gate() -> tuple[bool, str]:
res = _run_pytest(["-v", "tests/test_p0_release_gate.py"])
if res.returncode != 0:
return False, f"P0 tests failed:\n{res.stdout}\n{res.stderr}"
return True, "16/16 P0 release blockers & regression checks verified"
def check_updater_and_rollback() -> tuple[bool, str]:
res = _run_pytest(["-v", "tests/test_updater.py"])
if res.returncode != 0:
return False, f"Updater tests failed:\n{res.stdout}\n{res.stderr}"
return True, "Auto-updater, SHA-256 verification, and rollback verified"
def check_full_test_suite() -> tuple[bool, str]:
res = _run_pytest(["-v"])
if res.returncode != 0:
return False, f"Offline pytest suite failed:\n{res.stdout}\n{res.stderr}"
return True, "All unit and integration tests passed offline"
def check_zero_hardcoded_paths() -> tuple[bool, str]:
forbidden_patterns = [
re.compile(r"E:\\+Agent projects", re.IGNORECASE),
re.compile(r"C:\\+Users\\+trush", re.IGNORECASE),
re.compile(r"C:\\+Users\\+Ochenstarik", re.IGNORECASE),
]
src_dir = ROOT / "src"
violations = []
for f in src_dir.rglob("*.py"):
text = f.read_text(encoding="utf-8", errors="ignore")
for pat in forbidden_patterns:
if pat.search(text):
violations.append(f"{f.relative_to(ROOT)} matched {pat.pattern}")
if violations:
return False, f"Found hardcoded developer paths in src:\n" + "\n".join(violations)
return True, "Zero hardcoded developer paths in src/"
def _eval_ast_str_expr(node: ast.AST) -> str | None:
"""Evaluate constant string, binary string additions, or join of string constants in AST."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left = _eval_ast_str_expr(node.left)
right = _eval_ast_str_expr(node.right)
if left is not None and right is not None:
return left + right
elif isinstance(node, ast.Call):
# Check ''.join(('a', 'b', ...))
if isinstance(node.func, ast.Attribute) and node.func.attr == "join":
if isinstance(node.func.value, ast.Constant) and isinstance(node.func.value.value, str):
sep = node.func.value.value
if node.args and isinstance(node.args[0], (ast.List, ast.Tuple)):
parts = []
for elt in node.args[0].elts:
sub = _eval_ast_str_expr(elt)
if sub is None:
return None
parts.append(sub)
return sep.join(parts)
return None
def scan_file_for_secrets(file_path: Path) -> list[str]:
"""Scan a Python file using AST and regex for hardcoded secrets, keys, or obfuscated tokens."""
violations = []
content = file_path.read_text(encoding="utf-8", errors="ignore")
# 1. Regex checks for live credentials
patterns = [
(re.compile(r"""(?:sk-[a-zA-Z0-9]{32,}|opencode-[a-zA-Z0-9]{20,})"""), "Live API key pattern"),
(re.compile(r"""ya29\.[a-zA-Z0-9_-]{40,}"""), "Google OAuth user token"),
(re.compile(r"""-----BEGIN [A-Z ]*PRIVATE KEY-----"""), "Private Key header"),
(re.compile(r"""(?:bearer\s+[a-zA-Z0-9_\-\.]{40,})""", re.IGNORECASE), "Bearer token pattern"),
]
for pat, desc in patterns:
if pat.search(content):
violations.append(f"{desc} in {file_path.name}")
# 2. AST variable inspection
try:
tree = ast.parse(content, filename=str(file_path))
except Exception as e:
violations.append(f"Syntax error in {file_path.name}: {e}")
return violations
ALLOWED_PUBLIC_CLIENT_SECRET = "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"
ALLOWED_PUBLIC_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
SENSITIVE_VAR_NAMES = {"CLIENT_SECRET", "API_KEY", "ACCESS_TOKEN", "REFRESH_TOKEN", "SECRET_KEY", "PRIVATE_KEY"}
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id.upper() in SENSITIVE_VAR_NAMES:
# Check if value is a statically evaluable string expression (literal or concatenation)
val_str = _eval_ast_str_expr(node.value)
if val_str is not None:
# Obfuscation detection
if isinstance(node.value, (ast.BinOp, ast.Call)):
violations.append(f"Obfuscated secret assignment in variable '{target.id}' in {file_path.name}")
continue
if target.id == "CLIENT_SECRET" and val_str == ALLOWED_PUBLIC_CLIENT_SECRET:
continue
if target.id == "CLIENT_ID" and val_str == ALLOWED_PUBLIC_CLIENT_ID:
continue
if val_str.startswith("PLACEHOLDER") or val_str.startswith("dummy_") or val_str == "":
continue
violations.append(f"Hardcoded sensitive secret in variable '{target.id}' in {file_path.name}")
return violations
def check_security_zero_secrets() -> tuple[bool, str]:
secret_files = list(ROOT.rglob("auth.json")) + list(ROOT.rglob("*.secret")) + list(ROOT.rglob("*.key")) + list(ROOT.rglob(".env*"))
tracked_secrets = []
for sf in secret_files:
if ".git" not in str(sf) and "venv" not in str(sf) and "scratch" not in str(sf) and "example" not in str(sf):
tracked_secrets.append(str(sf.relative_to(ROOT)))
if tracked_secrets:
return False, f"Found sensitive secret files in repository:\n" + "\n".join(tracked_secrets)
# Scan all python files in src/
src_dir = ROOT / "src"
all_violations = []
for f in src_dir.rglob("*.py"):
violations = scan_file_for_secrets(f)
if violations:
all_violations.extend([f"{f.relative_to(ROOT)}: {v}" for v in violations])
if all_violations:
return False, f"Secret scanner detected violations in src/:\n" + "\n".join(all_violations)
return True, "Zero secret files, live tokens, or obfuscated secret assignments in src/"
# ═══════════════════════════════════════════════════════════════
# Publication Gate
# ═══════════════════════════════════════════════════════════════
#
# Проверка публикации отделена от офлайновой части, потому что раньше они были
# смешаны и обе были беззубыми. Измерено на прежней реализации:
# - при полном обрыве сети возвращался PASS ("check skipped");
# - при 404 на манифест возвращался PASS ("not yet published");
# - при 404 на пакет возвращался PASS ("pending upload");
# - при живом пакете печаталось PACKAGE_HASH_VERIFIED=True, хотя hashlib в
# файле не вызывался ни разу: скачивались байты 0-10 через заголовок Range,
# и этого хватало, чтобы объявить хеш проверенным.
# То есть ворота публикации пропускали релиз при любом исходе, включая полное
# отсутствие релиза.
#
# Теперь: офлайновые проверки (1-6) блокируют всегда; публикация проверяется
# по-настоящему — релиз есть, ассеты есть, пакет скачан целиком, SHA-256
# сошёлся с опубликованным. Блокирует она в режиме публикации (--publication
# или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне CI, где релиза для
# ветки нет и быть не должно, результат сообщается как есть и не блокирует.
# Неизмеренное называется "Н/Д" с причиной, а не выдаётся за проверенное.
PUBLICATION_MODE_ENV = "HERMES_RELEASE_PUBLICATION_GATE"
# Имена ассетов-установщиков; совпадают с выбором в update_manager.
PACKAGE_ASSET_NAMES = ("HermesHubSetup.exe", "hermes-hub-setup.sh", "install-linux.sh")
CHECKSUMS_ASSET_NAME = "checksums.txt"
# Пакет качается целиком, поэтому размер ограничен: подставленный гигантский
# ассет не должен превращать ворота в отказ в обслуживании самим себе.
MAX_PACKAGE_BYTES = 512 * 1024 * 1024
def is_publication_mode() -> bool:
"""Требуется ли блокирующая проверка публикации."""
return "--publication" in sys.argv or os.environ.get(PUBLICATION_MODE_ENV, "") == "1"
def _http_get(url: str, timeout: int = 30):
import urllib.request
req = urllib.request.Request(
url, headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
)
return urllib.request.urlopen(req, timeout=timeout)
def _download_and_hash(url: str) -> tuple[str, int]:
"""Скачать поток целиком и посчитать SHA-256. Никаких частичных диапазонов."""
import hashlib
digest = hashlib.sha256()
size = 0
with _http_get(url, timeout=120) as resp:
while True:
chunk = resp.read(1024 * 256)
if not chunk:
break
size += len(chunk)
if size > MAX_PACKAGE_BYTES:
raise ValueError(f"пакет превышает {MAX_PACKAGE_BYTES} байт")
digest.update(chunk)
return digest.hexdigest(), size
def _parse_checksums(text: str) -> dict[str, str]:
"""Разобрать строки вида '<sha256> <имя файла>'."""
table: dict[str, str] = {}
for line in text.splitlines():
parts = line.strip().split()
if len(parts) >= 2 and re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]):
table[parts[-1].lstrip("*")] = parts[0].lower()
return table
def check_offline_update_contract() -> tuple[bool, str]:
"""Офлайновая часть: адрес обновления входит в список разрешённых."""
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL, is_allowed_update_host
if not is_allowed_update_host(DEFAULT_UPDATE_URL):
return False, f"Адрес обновления вне списка разрешённых: {DEFAULT_UPDATE_URL}"
return True, f"Адрес обновления в списке разрешённых: {DEFAULT_UPDATE_URL}"
def check_publication_gate() -> tuple[bool, str]:
"""Релиз опубликован, ассеты на месте, SHA-256 пакета сошёлся.
В режиме публикации любой недостижимый шаг — отказ. Вне его отказ не
блокирует релиз, но и не выдаётся за успех.
"""
import urllib.error
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL
blocking = is_publication_mode()
def verdict(ok: bool, msg: str) -> tuple[bool, str]:
if ok:
return True, msg
if blocking:
return False, msg
return True, f"[НЕ БЛОКИРУЕТ: режим публикации не запрошен] {msg}"
# 1. Манифест релиза
try:
with _http_get(DEFAULT_UPDATE_URL) as resp:
if resp.status != 200:
return verdict(False, f"Манифест релиза ответил HTTP {resp.status}")
data = json.loads(resp.read().decode("utf-8-sig"))
except urllib.error.HTTPError as he:
return verdict(False, f"Манифест релиза недоступен: HTTP {he.code} ({DEFAULT_UPDATE_URL})")
except Exception as exc:
return verdict(False, f"Манифест релиза недоступен: {type(exc).__name__}: {exc}")
version = data.get("version") or str(data.get("tag_name", "")).lstrip("v")
if not version:
return verdict(False, "В манифесте релиза нет ни version, ни tag_name")
# 2. Ассеты
assets: dict[str, str] = {}
for asset in data.get("assets") or []:
name = asset.get("name")
url = asset.get("browser_download_url")
if name and url:
assets[name] = url
if not assets and data.get("package_url"):
assets[Path(data["package_url"]).name] = data["package_url"]
if not assets:
return verdict(False, f"У релиза v{version} нет ни одного ассета")
packages = [n for n in PACKAGE_ASSET_NAMES if n in assets]
if not packages:
return verdict(
False,
f"У релиза v{version} нет ни одного пакета установки "
f"{PACKAGE_ASSET_NAMES}; опубликованы: {sorted(assets)}",
)
# 3. Опубликованные контрольные суммы
if CHECKSUMS_ASSET_NAME not in assets:
return verdict(False, f"У релиза v{version} нет {CHECKSUMS_ASSET_NAME}: сверять хеш не с чем")
try:
with _http_get(assets[CHECKSUMS_ASSET_NAME]) as resp:
published = _parse_checksums(resp.read().decode("utf-8", errors="replace"))
except Exception as exc:
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не скачивается: {type(exc).__name__}: {exc}")
if not published:
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не содержит ни одной строки с SHA-256")
# 4. Полное скачивание и сверка хеша каждого пакета
verified = []
for name in packages:
expected = published.get(name)
if not expected:
return verdict(False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME}")
try:
actual, size = _download_and_hash(assets[name])
except Exception as exc:
return verdict(False, f"{name} не скачивается целиком: {type(exc).__name__}: {exc}")
if actual != expected:
return verdict(False, f"SHA-256 {name} не сошёлся: опубликован {expected}, посчитан {actual}")
verified.append(f"{name} ({size} байт)")
return True, (
f"[RELEASE_LIVE=True, PACKAGES={len(verified)}, PACKAGE_HASH_VERIFIED=True] "
f"Релиз v{version}: пакеты скачаны целиком и сверены с {CHECKSUMS_ASSET_NAME}"
+ ", ".join(verified)
)
def run_release_gate():
print("=" * 70)
print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})")
mode = "публикация (проверки 1-8 блокируют)" if is_publication_mode() else "офлайн (блокируют 1-7)"
print(f" Режим: {mode}")
print("=" * 70)
checks = [
("1. Version Consistency", "[UNIT VERIFIED]", check_version_consistency),
("2. P0 Release Blockers (16/16)", "[UNIT VERIFIED]", check_p0_release_gate),
("3. Auto-Updater & Rollback", "[INTEGRATION VERIFIED]", check_updater_and_rollback),
("4. Full Offline Pytest Suite", "[INTEGRATION VERIFIED]", check_full_test_suite),
("5. Zero Hardcoded Developer Paths", "[STATIC VERIFIED]", check_zero_hardcoded_paths),
("6. Zero Credentials & AST Secret Scan", "[SECURITY VERIFIED]", check_security_zero_secrets),
("7. Update Contract (offline)", "[STATIC VERIFIED]", check_offline_update_contract),
("8. Publication Gate", "[LIVE VERIFIED]", check_publication_gate),
]
all_passed = True
for title, tier, check_func in checks:
print(f"\nRunning {title} ({tier})...")
ok, msg = check_func()
if ok:
print(f" {tier} {msg}")
else:
print(f" [FAIL] {msg}")
all_passed = False
print("\n" + "=" * 70)
if all_passed:
print(f" [RELEASE GATE: PASSED] All criteria verified. Ready for Candidate v{__version__}")
print("=" * 70)
sys.exit(0)
else:
print(" [RELEASE GATE: FAILED] One or more checks failed. Release blocked.")
print("=" * 70)
sys.exit(1)
if __name__ == "__main__":
run_release_gate()