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>
This commit is contained in:
parent
a3373f9f76
commit
eac8352dc2
6 changed files with 480 additions and 76 deletions
|
|
@ -204,88 +204,179 @@ def check_security_zero_secrets() -> tuple[bool, str]:
|
|||
return True, "Zero secret files, live tokens, or obfuscated secret assignments in src/"
|
||||
|
||||
|
||||
def check_production_update_feed() -> tuple[bool, str]:
|
||||
"""Live verification of public release feed manifest and package URL."""
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 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
|
||||
import urllib.error
|
||||
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 host not in allowlist: {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:
|
||||
req = urllib.request.Request(
|
||||
DEFAULT_UPDATE_URL,
|
||||
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=6) as resp:
|
||||
if resp.status == 200:
|
||||
data = json.loads(resp.read().decode("utf-8-sig"))
|
||||
p_ver = data.get("version") or data.get("tag_name", "").lstrip("v")
|
||||
p_url = data.get("package_url")
|
||||
if not p_url and data.get("assets"):
|
||||
p_url = data["assets"][0].get("browser_download_url")
|
||||
if not p_url:
|
||||
p_url = data.get("html_url") or DEFAULT_UPDATE_URL
|
||||
|
||||
if not p_ver:
|
||||
return False, "Public update manifest is missing version or tag_name"
|
||||
|
||||
# Verify package URL reachability
|
||||
pkg_live = False
|
||||
pkg_status = "UNKNOWN"
|
||||
try:
|
||||
head_req = urllib.request.Request(
|
||||
p_url,
|
||||
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
||||
)
|
||||
# Use Range header to avoid downloading huge binaries
|
||||
head_req.add_header("Range", "bytes=0-10")
|
||||
with urllib.request.urlopen(head_req, timeout=6) as pkg_resp:
|
||||
if pkg_resp.status in (200, 206, 302):
|
||||
pkg_live = True
|
||||
pkg_status = "PACKAGE_LIVE"
|
||||
except urllib.error.HTTPError as pkg_he:
|
||||
if pkg_he.code == 404:
|
||||
pkg_status = "PENDING_RELEASE_UPLOAD_404"
|
||||
else:
|
||||
pkg_status = f"HTTP_{pkg_he.code}"
|
||||
except Exception as pkg_ex:
|
||||
pkg_status = f"CHECK_SKIPPED_{pkg_ex}"
|
||||
|
||||
manifest_live = True
|
||||
package_live = False
|
||||
hash_verified = False
|
||||
|
||||
if pkg_live:
|
||||
package_live = True
|
||||
# If package is live, verify hash on partial bytes or full stream
|
||||
hash_verified = True
|
||||
return True, f"[MANIFEST_LIVE=True, PACKAGE_LIVE=True, PACKAGE_HASH_VERIFIED=True] Manifest live (v{p_ver}) and release asset verified at {p_url}"
|
||||
elif pkg_status == "PENDING_RELEASE_UPLOAD_404":
|
||||
return True, (
|
||||
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False (Pending Upload 404), PACKAGE_HASH_VERIFIED=Offline Validated] "
|
||||
f"Manifest is live (v{p_ver}), release zip ready for GitHub Release asset upload. Offline updater tests passed."
|
||||
)
|
||||
else:
|
||||
return True, (
|
||||
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False ({pkg_status}), PACKAGE_HASH_VERIFIED=Offline Validated] "
|
||||
f"Manifest live (v{p_ver}). Offline updater tests passed."
|
||||
)
|
||||
|
||||
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:
|
||||
if he.code == 404:
|
||||
return True, f"[MANIFEST_LIVE=False, PACKAGE_LIVE=False] Public manifest not yet published (HTTP 404). Offline updater tests passed."
|
||||
return False, f"HTTP Error checking update feed: {he}"
|
||||
return verdict(False, f"Манифест релиза недоступен: HTTP {he.code} ({DEFAULT_UPDATE_URL})")
|
||||
except Exception as exc:
|
||||
return True, f"[MANIFEST_LIVE=Unknown, PACKAGE_LIVE=Unknown] Public feed check skipped ({exc}). Offline updater tests passed."
|
||||
return verdict(False, f"Манифест релиза недоступен: {type(exc).__name__}: {exc}")
|
||||
|
||||
return True, "Production update feed verified"
|
||||
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 = [
|
||||
|
|
@ -295,7 +386,8 @@ def run_release_gate():
|
|||
("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. Public Production Update Feed", "[LIVE STATUS]", check_production_update_feed),
|
||||
("7. Update Contract (offline)", "[STATIC VERIFIED]", check_offline_update_contract),
|
||||
("8. Publication Gate", "[LIVE VERIFIED]", check_publication_gate),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
|
|
|
|||
|
|
@ -161,7 +161,11 @@ class TelemetryService:
|
|||
if p.is_file():
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
|
||||
# safe_load, а не safe_dump: dump сериализует текст обратно
|
||||
# в строку, isinstance(data, dict) никогда не выполнялось, и
|
||||
# таблица цен из pricing.yaml не загружалась ни разу. Тихо:
|
||||
# ошибку глушил except.
|
||||
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and "pricing" in data:
|
||||
self._pricing_table = dict(data["pricing"])
|
||||
return
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import time
|
|||
import dataclasses
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.version import __version__
|
||||
|
|
@ -105,6 +106,63 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
|||
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
|
||||
return True
|
||||
|
||||
# Небезопасные методы принимаются только от собственного интерфейса.
|
||||
#
|
||||
# На loopback токен не требуется вовсе (см. get_auth_token), а POST /api/action
|
||||
# меняет состояние: удаляет учётные данные, чистит аккаунты, переключает
|
||||
# маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает
|
||||
# ПРОЧИТАТЬ ответ, а не отправить запрос.
|
||||
#
|
||||
# Проверено на конфигурации по умолчанию (web_api_host=127.0.0.1): POST с
|
||||
# Content-Type: text/plain уходит кросс-сайтом без предварительного запроса
|
||||
# (простой запрос по правилам CORS), а request.json() разбирает тело
|
||||
# независимо от Content-Type. Запрос с Origin: https://evil.example.com и без
|
||||
# токена доходил до исполнителя действий — отвечало уже само действие.
|
||||
#
|
||||
# Проверяется Sec-Fetch-Site, а при его отсутствии — Origin против адреса, по
|
||||
# которому пришёл запрос. Отсутствие обоих заголовков означает не-браузерного
|
||||
# клиента (curl, собственный CLI): браузер на кросс-сайтовый POST заголовок
|
||||
# Origin ставит обязательно, поэтому его отсутствие подделкой не является.
|
||||
_SAME_SITE_FETCH_VALUES = {"same-origin", "none"}
|
||||
|
||||
|
||||
def _configured_allowed_origins() -> set:
|
||||
raw = str(_web_settings().get("web_api_allowed_origins", "")).split(",")
|
||||
return {o.strip() for o in raw if o.strip() and o.strip() != "*"}
|
||||
|
||||
|
||||
def require_same_origin(request: Request) -> bool:
|
||||
"""Отклонить межсайтовый небезопасный запрос до того, как он что-то изменит."""
|
||||
fetch_site = (request.headers.get("sec-fetch-site") or "").strip().lower()
|
||||
if fetch_site:
|
||||
if fetch_site in _SAME_SITE_FETCH_VALUES:
|
||||
return True
|
||||
if request.headers.get("origin", "") in _configured_allowed_origins():
|
||||
return True
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Межсайтовый запрос отклонён (Sec-Fetch-Site: {fetch_site})",
|
||||
)
|
||||
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return True
|
||||
|
||||
host = (request.headers.get("host") or "").strip().lower()
|
||||
try:
|
||||
origin_host = urlparse(origin).netloc.strip().lower()
|
||||
except Exception:
|
||||
origin_host = ""
|
||||
if origin_host and host and origin_host == host:
|
||||
return True
|
||||
if origin in _configured_allowed_origins():
|
||||
return True
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Origin не совпадает с адресом хаба: межсайтовый запрос отклонён",
|
||||
)
|
||||
|
||||
|
||||
# Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса.
|
||||
#
|
||||
# get_installed_commit() читает манифест с диска при каждом вызове, поэтому
|
||||
|
|
@ -283,7 +341,11 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
|||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||||
|
||||
@app.post("/api/action")
|
||||
async def handle_action(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def handle_action(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
|
|
@ -356,7 +418,11 @@ def get_skills_endpoint(authorized: bool = Depends(get_auth_token)):
|
|||
|
||||
|
||||
@app.post("/api/skills/assign")
|
||||
async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def assign_skill_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Assign a skill to a specific subagent."""
|
||||
from antigravity_provider.router.skills_service import SkillsService
|
||||
try:
|
||||
|
|
@ -377,7 +443,11 @@ async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get
|
|||
|
||||
|
||||
@app.post("/api/skills/unassign")
|
||||
async def unassign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def unassign_skill_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Remove an assigned skill from a subagent."""
|
||||
from antigravity_provider.router.skills_service import SkillsService
|
||||
try:
|
||||
|
|
@ -406,7 +476,11 @@ def get_skills_usage_endpoint(authorized: bool = Depends(get_auth_token)):
|
|||
|
||||
|
||||
@app.post("/api/skills/diagnose")
|
||||
async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def diagnose_skill_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Run SkillDoctor diagnostics on a skill by name, filepath, or raw content."""
|
||||
from antigravity_provider.router.skills_service import SkillsService
|
||||
try:
|
||||
|
|
@ -456,7 +530,11 @@ def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends
|
|||
|
||||
|
||||
@app.post("/api/compression/test")
|
||||
async def test_compression_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def test_compression_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Execute test context compression on synthetic benchmark prompt."""
|
||||
from antigravity_provider.router.settings_service import get_hub_settings
|
||||
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||
|
|
|
|||
|
|
@ -476,3 +476,70 @@ def test_normal_work_inside_project_still_allowed(guard_env):
|
|||
for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]:
|
||||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
||||
assert allowed, f"защита мешает штатной работе: {cmd} ({reason})"
|
||||
|
||||
|
||||
# ── HUB-1: небезопасные методы не принимаются с чужой страницы ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
"""Клиент при конфигурации по умолчанию: web_api_host=127.0.0.1, токен не нужен."""
|
||||
import antigravity_provider.router.web.server as srv
|
||||
|
||||
with patch.object(srv, "_web_settings", return_value={"web_api_host": "127.0.0.1"}):
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
_UNSAFE_ENDPOINTS = [
|
||||
("/api/action", '{"action": "clear_accounts", "data": {}}'),
|
||||
("/api/skills/assign", '{"skill": "x", "profile": "y"}'),
|
||||
("/api/skills/unassign", '{"skill": "x", "profile": "y"}'),
|
||||
("/api/skills/diagnose", '{"skill": "x"}'),
|
||||
("/api/compression/test", '{"text": "x"}'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
|
||||
def test_cross_site_post_is_rejected(loopback_client, path, body):
|
||||
"""Межсайтовый POST отклоняется до того, как что-либо изменит.
|
||||
|
||||
На loopback токен не требуется, а действия меняют состояние: удаляют
|
||||
учётные данные, чистят аккаунты, переключают маршрутизацию. CORS от этого
|
||||
не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено:
|
||||
POST с Content-Type text/plain уходит кросс-сайтом без предварительного
|
||||
запроса, request.json() разбирает тело независимо от Content-Type, и
|
||||
запрос с чужим Origin доходил до исполнителя действий.
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "text/plain;charset=UTF-8",
|
||||
"Origin": "https://evil.example.com",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
}
|
||||
res = loopback_client.post(path, content=body, headers=headers)
|
||||
assert res.status_code == 403, f"{path} принял межсайтовый запрос: HTTP {res.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
|
||||
def test_cross_site_post_rejected_without_fetch_metadata(loopback_client, path, body):
|
||||
"""Браузер без Sec-Fetch-* всё равно ставит Origin — по нему и отклоняем."""
|
||||
headers = {"Content-Type": "text/plain", "Origin": "https://evil.example.com"}
|
||||
res = loopback_client.post(path, content=body, headers=headers)
|
||||
assert res.status_code == 403, f"{path} принял запрос с чужим Origin: HTTP {res.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label, headers",
|
||||
[
|
||||
("собственный интерфейс", {"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"}),
|
||||
("адресная строка", {"Sec-Fetch-Site": "none"}),
|
||||
("не-браузерный клиент", {}),
|
||||
],
|
||||
)
|
||||
def test_own_interface_and_cli_still_work(loopback_client, label, headers):
|
||||
"""Защита не должна мешать собственному интерфейсу и не-браузерным клиентам."""
|
||||
res = loopback_client.post(
|
||||
"/api/action",
|
||||
json={"action": "___нет_такого___", "data": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code != 403, f"{label} отклонён межсайтовой защитой"
|
||||
|
|
|
|||
|
|
@ -268,7 +268,10 @@ def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(simulated_
|
|||
with patch("antigravity_provider.updater.update_manager.sys") as mock_sys:
|
||||
mock_sys.platform = "win32" if is_win else "linux"
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
# На Windows os.getuid не существует; ветка Linux падала бы на нём в
|
||||
# общий except и возвращала «остановлено» никого не остановив. create=True
|
||||
# позволяет подставить его там, где его нет.
|
||||
with patch("os.getuid", return_value=1000, create=True), patch("subprocess.run") as mock_run:
|
||||
# wmic и pgrep перечисляют один и тот же набор: чужой PID и свой.
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -461,3 +461,163 @@ def test_s4_secret_scanner_ast_detection(tmp_path):
|
|||
clean_file.write_text('def hello(): return "world"\n', encoding="utf-8")
|
||||
v3 = scan_file_for_secrets(clean_file)
|
||||
assert len(v3) == 0
|
||||
|
||||
|
||||
# ── HUB-1: ворота публикации не пропускают релиз при любом исходе ──
|
||||
|
||||
|
||||
def _load_release_gate():
|
||||
import importlib
|
||||
import sys
|
||||
scripts_dir = str(Path(__file__).resolve().parent.parent / "scripts")
|
||||
if scripts_dir not in sys.path:
|
||||
sys.path.insert(0, scripts_dir)
|
||||
import release_gate
|
||||
importlib.reload(release_gate)
|
||||
return release_gate
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"failure, expected_in_message",
|
||||
[
|
||||
("network", "недоступен"),
|
||||
("http_404", "404"),
|
||||
("no_assets", "ассет"),
|
||||
("no_checksums", "checksums.txt"),
|
||||
("hash_mismatch", "не сошёлся"),
|
||||
],
|
||||
)
|
||||
def test_publication_gate_blocks_instead_of_failing_open(monkeypatch, failure, expected_in_message):
|
||||
"""Недостижимая публикация — отказ, а не PASS.
|
||||
|
||||
Измерено на прежней реализации: при обрыве сети, при 404 на манифест и при
|
||||
404 на пакет возвращался PASS. Ворота пропускали релиз при любом исходе,
|
||||
включая полное отсутствие релиза, а строка PACKAGE_HASH_VERIFIED=True
|
||||
печаталась при том, что hashlib в файле не вызывался ни разу — хеш был
|
||||
объявлен проверенным после чтения одиннадцати байт через заголовок Range.
|
||||
"""
|
||||
import urllib.error
|
||||
release_gate = _load_release_gate()
|
||||
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
|
||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
||||
|
||||
manifest = {
|
||||
"tag_name": "v9.9.9",
|
||||
"assets": [
|
||||
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
|
||||
],
|
||||
}
|
||||
if failure == "no_assets":
|
||||
manifest["assets"] = []
|
||||
if failure == "no_checksums":
|
||||
manifest["assets"] = [manifest["assets"][0]]
|
||||
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload: bytes):
|
||||
self._payload = payload
|
||||
|
||||
def read(self, *_a):
|
||||
payload, self._payload = self._payload, b""
|
||||
return payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_a):
|
||||
return False
|
||||
|
||||
def fake_get(url, timeout=30):
|
||||
if failure == "network":
|
||||
raise urllib.error.URLError("сети нет")
|
||||
if failure == "http_404":
|
||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
||||
if url.endswith("checksums.txt"):
|
||||
return _Resp(b"%s HermesHubSetup.exe\n" % (b"a" * 64))
|
||||
if url.endswith("setup.exe"):
|
||||
return _Resp(b"payload-with-a-different-hash")
|
||||
return _Resp(json.dumps(manifest).encode("utf-8"))
|
||||
|
||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
||||
|
||||
ok, msg = release_gate.check_publication_gate()
|
||||
assert ok is False, f"ворота пропустили релиз при отказе '{failure}': {msg}"
|
||||
assert expected_in_message in msg, f"причина отказа не названа: {msg!r}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_publication_gate_hashes_the_whole_package(monkeypatch):
|
||||
"""Успех объявляется только после полного скачивания и сверки SHA-256."""
|
||||
import hashlib
|
||||
release_gate = _load_release_gate()
|
||||
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
|
||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
||||
|
||||
package = b"hermes hub installer payload"
|
||||
real_sha = hashlib.sha256(package).hexdigest()
|
||||
read_bytes = {"total": 0}
|
||||
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload: bytes, count: bool = False):
|
||||
self._payload = payload
|
||||
self._count = count
|
||||
|
||||
def read(self, *_a):
|
||||
payload, self._payload = self._payload, b""
|
||||
if self._count:
|
||||
read_bytes["total"] += len(payload)
|
||||
return payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_a):
|
||||
return False
|
||||
|
||||
manifest = {
|
||||
"tag_name": "v9.9.9",
|
||||
"assets": [
|
||||
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
|
||||
],
|
||||
}
|
||||
|
||||
def fake_get(url, timeout=30):
|
||||
if url.endswith("checksums.txt"):
|
||||
return _Resp(f"{real_sha} HermesHubSetup.exe\n".encode("utf-8"))
|
||||
if url.endswith("setup.exe"):
|
||||
return _Resp(package, count=True)
|
||||
return _Resp(json.dumps(manifest).encode("utf-8"))
|
||||
|
||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
||||
|
||||
ok, msg = release_gate.check_publication_gate()
|
||||
assert ok is True, msg
|
||||
assert "PACKAGE_HASH_VERIFIED=True" in msg
|
||||
assert read_bytes["total"] == len(package), (
|
||||
f"пакет должен быть прочитан целиком, прочитано {read_bytes['total']} из {len(package)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_offline_run_does_not_claim_publication_verified(monkeypatch):
|
||||
"""Без режима публикации отсутствие релиза не блокирует, но и не врёт."""
|
||||
import urllib.error
|
||||
release_gate = _load_release_gate()
|
||||
monkeypatch.delenv(release_gate.PUBLICATION_MODE_ENV, raising=False)
|
||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
||||
|
||||
def fake_get(url, timeout=30):
|
||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
||||
|
||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
||||
|
||||
ok, msg = release_gate.check_publication_gate()
|
||||
assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза"
|
||||
assert "НЕ БЛОКИРУЕТ" in msg
|
||||
assert "PACKAGE_HASH_VERIFIED=True" not in msg, "непроверенное не должно объявляться проверенным"
|
||||
|
|
|
|||
Loading…
Reference in a new issue