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>
545 lines
26 KiB
Python
545 lines
26 KiB
Python
"""Test suite for Task A37: Agent Isolation, Credential Protection, Workspace Boundaries, and Forensic Audit."""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
import pytest
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from antigravity_provider.router.security_guard import (
|
||
WorkspaceBoundaryGuard,
|
||
NetworkBoundaryGuard,
|
||
AgentWorkspaceGuard,
|
||
BoundaryViolationError,
|
||
NetworkBoundaryViolationError,
|
||
scrub_secrets,
|
||
scrub_string,
|
||
get_workspace_guard,
|
||
)
|
||
from antigravity_provider.router.unified_health import EventLogService
|
||
from antigravity_provider.router.action_handler import ActionExecutor, do_delete_credentials
|
||
from antigravity_provider.router.web.server import app
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# P0-1: Workspace Boundary & Destructive Operations Guard Tests
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestWorkspaceBoundaryGuard:
|
||
"""Tests for workspace boundaries, traversal prevention, and credential defense."""
|
||
|
||
def test_deletion_outside_allowed_workspace_is_blocked(self, tmp_path):
|
||
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "allowed_workspace"])
|
||
(tmp_path / "allowed_workspace").mkdir(parents=True, exist_ok=True)
|
||
outside_file = tmp_path / "outside_workspace" / "sensitive_file.txt"
|
||
outside_file.parent.mkdir(parents=True, exist_ok=True)
|
||
outside_file.write_text("critical data", encoding="utf-8")
|
||
|
||
ok, reason, safe_alt = guard.validate_path(outside_file, operation="delete")
|
||
assert ok is False
|
||
assert "за пределами разрешённых рабочих областей" in reason
|
||
assert safe_alt is not None
|
||
|
||
with pytest.raises(BoundaryViolationError):
|
||
guard.safe_delete_file(outside_file)
|
||
|
||
def test_unconditional_protection_of_credential_pools_and_ssh(self, tmp_path, monkeypatch):
|
||
hermes_home = tmp_path / ".hermes"
|
||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||
|
||
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[hermes_home])
|
||
|
||
# 1. agy_profiles
|
||
agy_profile = hermes_home / "agy_profiles" / "ag-w1" / "auth.json"
|
||
agy_profile.parent.mkdir(parents=True, exist_ok=True)
|
||
agy_profile.write_text("token", encoding="utf-8")
|
||
|
||
ok, reason, alt = guard.validate_path(agy_profile, operation="delete")
|
||
assert ok is False
|
||
assert "защищённого каталога учётных данных" in reason or "защищённым" in reason
|
||
assert "delete_credentials" in alt
|
||
|
||
# 2. hub_settings.json
|
||
settings_p = hermes_home / "hub_settings.json"
|
||
settings_p.write_text("{}", encoding="utf-8")
|
||
ok, reason, _ = guard.validate_path(settings_p, operation="delete")
|
||
assert ok is False
|
||
|
||
# 3. .ssh
|
||
fake_ssh = Path.home() / ".ssh" / "id_rsa"
|
||
ok, reason, _ = guard.validate_path(fake_ssh, operation="delete")
|
||
assert ok is False
|
||
|
||
def test_path_traversal_and_symlink_bypass_prevention(self, tmp_path):
|
||
ws_dir = tmp_path / "workspace"
|
||
ws_dir.mkdir(parents=True, exist_ok=True)
|
||
secret_outside = tmp_path / "secret.txt"
|
||
secret_outside.write_text("secret", encoding="utf-8")
|
||
|
||
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[ws_dir])
|
||
|
||
# Relative path traversal attack: workspace/../../secret.txt
|
||
traversal_path = ws_dir / "subdir" / ".." / ".." / "secret.txt"
|
||
ok, reason, _ = guard.validate_path(traversal_path, operation="delete")
|
||
assert ok is False
|
||
assert "за пределами" in reason
|
||
|
||
# Symlink attack: symlink inside workspace pointing outside
|
||
link_inside = ws_dir / "link_to_outside"
|
||
try:
|
||
link_inside.symlink_to(secret_outside)
|
||
ok_symlink, reason_symlink, _ = guard.validate_path(link_inside, operation="delete")
|
||
assert ok_symlink is False
|
||
assert "за пределами" in reason_symlink
|
||
except OSError:
|
||
# On platforms without symlink privileges, skip symlink creation
|
||
pass
|
||
|
||
def test_shell_command_analysis_and_classification(self, tmp_path):
|
||
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path / "project"])
|
||
project_dir = tmp_path / "project"
|
||
project_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 1. Dangerous rm -rf / or outside
|
||
ok, reason, _ = guard.validate_command("rm -rf /etc/passwd", cwd=project_dir)
|
||
assert ok is False
|
||
|
||
# 2. Windows del C:\Windows
|
||
ok, reason, _ = guard.validate_command("del /f /q C:\\Windows\\System32", cwd=project_dir)
|
||
assert ok is False
|
||
|
||
# 3. Safe rm within project
|
||
ok, reason, _ = guard.validate_command("rm temp_file.log", cwd=project_dir)
|
||
assert ok is True
|
||
|
||
def test_dry_run_deletion_reports_metadata_without_deleting(self, tmp_path):
|
||
guard = WorkspaceBoundaryGuard(additional_allowed_roots=[tmp_path])
|
||
test_dir = tmp_path / "to_delete"
|
||
test_dir.mkdir(parents=True, exist_ok=True)
|
||
file1 = test_dir / "file1.txt"
|
||
file2 = test_dir / "file2.txt"
|
||
file1.write_text("hello 1", encoding="utf-8")
|
||
file2.write_text("hello 222", encoding="utf-8")
|
||
|
||
res = guard.dry_run_deletion([test_dir])
|
||
assert res["dry_run"] is True
|
||
assert res["allowed"] is True
|
||
assert res["total_dirs"] >= 1
|
||
assert res["total_files"] == 0 # directory item
|
||
assert res["total_bytes"] == len("hello 1".encode()) + len("hello 222".encode())
|
||
assert file1.exists()
|
||
assert file2.exists()
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# P0-2: Agent Isolation & Credential Separation Tests
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestAgentIsolation:
|
||
"""Tests for dedicated agent workspaces, role-scoped credentials, and actor attribution."""
|
||
|
||
def test_dedicated_agent_workspace_creation(self, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
||
ws_dev1 = AgentWorkspaceGuard.get_agent_workspace_dir("developer-1")
|
||
ws_rev = AgentWorkspaceGuard.get_agent_workspace_dir("code-reviewer")
|
||
|
||
assert ws_dev1.is_dir()
|
||
assert ws_rev.is_dir()
|
||
assert ws_dev1 != ws_rev
|
||
assert "agent-developer-1" in str(ws_dev1)
|
||
assert "agent-code-reviewer" in str(ws_rev)
|
||
|
||
def test_role_scoped_credential_isolation(self, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
||
env_dev1 = AgentWorkspaceGuard.build_agent_subprocess_env(
|
||
agent_id="developer-1",
|
||
role="coder",
|
||
assigned_profile_id="ag-w1",
|
||
)
|
||
|
||
assert env_dev1["HERMES_AGENT_ID"] == "developer-1"
|
||
assert env_dev1["HERMES_AGENT_ROLE"] == "coder"
|
||
assert "ag-w1" in env_dev1["HOME"]
|
||
assert "ag-w1" in env_dev1["USERPROFILE"]
|
||
|
||
# Ensure no cross-agent foreign API keys leak
|
||
assert "ANTHROPIC_API_KEY" not in env_dev1
|
||
assert "OPENAI_API_KEY" not in env_dev1
|
||
|
||
def test_actor_attribution_in_action_executor(self, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
||
EventLogService._instance = None
|
||
event_svc = EventLogService.get()
|
||
|
||
res = ActionExecutor.execute(
|
||
action="dry_run_delete",
|
||
data={"paths": [str(tmp_path)]},
|
||
actor="agent:developer-1",
|
||
)
|
||
|
||
assert res["ok"] is True
|
||
events = event_svc.get_events(limit=5)
|
||
actor_events = [e for e in events if e.actor == "agent:developer-1"]
|
||
assert len(actor_events) > 0
|
||
assert actor_events[0].action == "dry_run_delete"
|
||
assert actor_events[0].outcome == "dry_run"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# P0-3: Network Boundary Guard & CORS Tests
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestNetworkBoundaryAndCORS:
|
||
"""Tests for outbound destination whitelist, strict CORS, and network HTTP disclosures."""
|
||
|
||
def test_outbound_destination_whitelist(self):
|
||
# Allowed AI providers & releases
|
||
assert NetworkBoundaryGuard.is_host_allowed("https://api.anthropic.com/v1/messages") is True
|
||
assert NetworkBoundaryGuard.is_host_allowed("https://generativelanguage.googleapis.com") is True
|
||
assert NetworkBoundaryGuard.is_host_allowed("https://api.github.com/repos/releases") is True
|
||
assert NetworkBoundaryGuard.is_host_allowed("http://127.0.0.1:11434/api/tags") is True
|
||
assert NetworkBoundaryGuard.is_host_allowed("http://localhost:8000/v1/models") is True
|
||
|
||
# Blocked external / rogue hosts
|
||
assert NetworkBoundaryGuard.is_host_allowed("https://evil-hacker.com/exfil") is False
|
||
assert NetworkBoundaryGuard.is_host_allowed("http://internal-artifactory.local:8081") is False
|
||
|
||
with pytest.raises(NetworkBoundaryViolationError):
|
||
NetworkBoundaryGuard.validate_outbound_url("http://internal-artifactory.local/coordination")
|
||
|
||
def test_cors_rejects_wildcard_with_credentials(self, client):
|
||
response = client.options(
|
||
"/api/snapshot",
|
||
headers={"Origin": "https://attacker.example.com", "Access-Control-Request-Method": "GET"},
|
||
)
|
||
# Should NOT return allow-origin: * or echo back attacker origin
|
||
allow_origin = response.headers.get("access-control-allow-origin")
|
||
assert allow_origin != "*"
|
||
assert allow_origin != "https://attacker.example.com"
|
||
|
||
def test_network_http_warning_on_external_bind(self, client, monkeypatch):
|
||
from antigravity_provider.router import settings_service
|
||
|
||
fake_settings = {
|
||
"web_api_host": "0.0.0.0",
|
||
"web_api_token": "secret_token_123",
|
||
}
|
||
monkeypatch.setattr(settings_service, "get_hub_settings", lambda: fake_settings)
|
||
|
||
res = client.get("/api/settings", headers={"X-Hub-Token": "secret_token_123"})
|
||
assert res.status_code == 200
|
||
data = res.json()
|
||
assert "network_security" in data
|
||
assert data["network_security"]["is_external_bind"] is True
|
||
assert "открытого HTTP" in data["network_security"]["warning"]
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# P0-4: Forensic Audit Logging & Secret Scrubbing Tests
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestForensicAuditAndSecretScrubbing:
|
||
"""Tests for secret scrubbing and forensic traceability."""
|
||
|
||
def test_secret_scrubbing_removes_tokens_and_bearer_headers(self):
|
||
dirty = {
|
||
"user_prompt": "Please summarize this commit",
|
||
"auth_header": "Bearer sk-proj-1234567890abcdef123456",
|
||
"api_key": "secret_api_key_value",
|
||
"nested": {
|
||
"token": "ghp_12345678901234567890",
|
||
"safe_field": "visible value",
|
||
},
|
||
}
|
||
|
||
clean = scrub_secrets(dirty)
|
||
assert clean["api_key"] == "***"
|
||
assert clean["nested"]["token"] == "***"
|
||
assert clean["nested"]["safe_field"] == "visible value"
|
||
assert "sk-proj" not in clean["auth_header"]
|
||
|
||
def test_string_scrubber_masks_inline_secrets(self):
|
||
msg = "Request failed with error: Bearer ghp_abcdef1234567890 on host https://api.openai.com?access_token=sk-9876543210"
|
||
scrubbed = scrub_string(msg)
|
||
assert "ghp_abcdef" not in scrubbed
|
||
assert "sk-987654" not in scrubbed
|
||
assert "Bearer ***" in scrubbed
|
||
assert "access_token=***" in scrubbed
|
||
|
||
def test_audit_log_captures_actor_and_outcome(self, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
EventLogService._instance = None
|
||
svc = EventLogService.get()
|
||
|
||
svc.log(
|
||
category="account",
|
||
message="Удаление профиля grok-worker-1",
|
||
actor="reviewer:manual",
|
||
action="delete_credentials",
|
||
target_profile="grok-worker-1",
|
||
target_role="code-reviewer",
|
||
outcome="success",
|
||
level="warning",
|
||
)
|
||
|
||
events = svc.get_events(limit=5)
|
||
assert len(events) >= 1
|
||
ev = events[0]
|
||
assert ev.actor == "reviewer:manual"
|
||
assert ev.action == "delete_credentials"
|
||
assert ev.target_profile == "grok-worker-1"
|
||
assert ev.target_role == "code-reviewer"
|
||
assert ev.outcome == "success"
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# P0-5: Live Deletion Confirmation & Safe Operations Tests
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestCredentialsDeletionGuard:
|
||
"""Tests for explicit credential deletion confirmation and dry-run."""
|
||
|
||
def test_delete_credentials_dry_run(self, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||
|
||
auth_p = get_profile_auth_path("grok", "grok-worker-1")
|
||
auth_p.parent.mkdir(parents=True, exist_ok=True)
|
||
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
|
||
|
||
res = ActionExecutor.execute(
|
||
action="delete_credentials",
|
||
data={"provider": "grok", "profile_id": "grok-worker-1", "dry_run": True},
|
||
actor="reviewer:manual",
|
||
)
|
||
|
||
assert res["ok"] is True
|
||
assert res.get("dry_run") is True
|
||
assert auth_p.is_file() # File MUST NOT be deleted during dry-run
|
||
|
||
def test_delete_credentials_unconfirmed_request(self, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||
|
||
auth_p = get_profile_auth_path("grok", "grok-worker-1")
|
||
auth_p.parent.mkdir(parents=True, exist_ok=True)
|
||
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
|
||
|
||
res = ActionExecutor.execute(
|
||
action="delete_credentials",
|
||
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": False},
|
||
actor="agent:developer-1",
|
||
)
|
||
|
||
assert res["ok"] is False
|
||
assert res.get("confirmation_required") is True
|
||
assert auth_p.is_file() # File MUST NOT be deleted without confirmation
|
||
|
||
def test_delete_credentials_confirmed_success(self, tmp_path, monkeypatch):
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
from antigravity_provider.router.profile_manager import get_profile_auth_path
|
||
|
||
auth_p = get_profile_auth_path("grok", "grok-worker-1")
|
||
auth_p.parent.mkdir(parents=True, exist_ok=True)
|
||
auth_p.write_text("{\"key\": \"val\"}", encoding="utf-8")
|
||
|
||
res = ActionExecutor.execute(
|
||
action="delete_credentials",
|
||
data={"provider": "grok", "profile_id": "grok-worker-1", "confirmed": True},
|
||
actor="user:web",
|
||
)
|
||
|
||
assert res["ok"] is True
|
||
assert not auth_p.is_file()
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
return TestClient(app)
|
||
|
||
def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
|
||
"""Команда с тильдой не должна обходить защиту каталогов учётных данных.
|
||
|
||
validate_command не раскрывал "~" и "$HOME" перед проверкой. Путь
|
||
"~/.hermes/agy_profiles" не считался абсолютным, склеивался с каталогом
|
||
проекта в путь с буквальным "~" внутри и признавался допустимым.
|
||
|
||
Измерено на реализации: "rm -rf ~/.hermes/agy_profiles" проходило, а та же
|
||
команда с абсолютным путём отклонялась. То есть самый естественный способ
|
||
написать опасную команду обходил защиту ровно в том месте, ради которого
|
||
она и делалась.
|
||
"""
|
||
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
|
||
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||
guard = WorkspaceBoundaryGuard()
|
||
|
||
must_reject = [
|
||
"rm -rf ~/.hermes/agy_profiles",
|
||
"rm -rf ~/.ssh",
|
||
"rm -rf $HOME/.hermes",
|
||
]
|
||
for cmd in must_reject:
|
||
allowed, reason, _alt = guard.validate_command(cmd)
|
||
assert not allowed, f"команда с тильдой прошла мимо защиты: {cmd} ({reason})"
|
||
|
||
# Обычная работа внутри проекта не должна страдать.
|
||
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
|
||
assert allowed, "защита мешает штатной работе внутри проекта"
|
||
|
||
|
||
# ── HUB-1: граница обязана держаться одинаково на Windows и Linux ──
|
||
#
|
||
# Инвариант A37 падал только на Windows-раннере: "rm -rf $HOME/.hermes"
|
||
# проходил мимо защиты, потому что переменной HOME в окружении Windows нет.
|
||
# Прогон на одной системе этого не показывал. Тесты ниже воспроизводят
|
||
# окружение обеих систем на любой из них, поэтому дыра больше не может
|
||
# спрятаться за тем, где именно запущен CI.
|
||
|
||
_DESTRUCTIVE_BOTH_DIALECTS = [
|
||
"rm -rf ~/.hermes/agy_profiles",
|
||
"rm -rf ~/.ssh",
|
||
"rm -rf $HOME/.hermes",
|
||
"rm -rf ${HOME}/.hermes",
|
||
r"rm -rf %USERPROFILE%\.hermes",
|
||
r"del /f /q %USERPROFILE%\.ssh",
|
||
]
|
||
|
||
|
||
@pytest.fixture
|
||
def guard_env(monkeypatch, tmp_path):
|
||
"""Guard с HERMES_HOME во временном каталоге — вне домашнего и вне проекта."""
|
||
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
|
||
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||
return WorkspaceBoundaryGuard()
|
||
|
||
|
||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
||
@pytest.mark.parametrize("cmd", _DESTRUCTIVE_BOTH_DIALECTS)
|
||
def test_home_directed_destruction_rejected_on_both_systems(monkeypatch, guard_env, simulated_os, cmd):
|
||
"""Удаление по домашнему каталогу отклоняется в обоих окружениях.
|
||
|
||
Окружение Windows отличается от Linux ровно тем, из-за чего защита и
|
||
расходилась: HOME не задан, домашний каталог известен через USERPROFILE.
|
||
"""
|
||
if simulated_os == "windows":
|
||
monkeypatch.delenv("HOME", raising=False)
|
||
monkeypatch.setenv("USERPROFILE", str(Path.home()))
|
||
else:
|
||
monkeypatch.setenv("HOME", str(Path.home()))
|
||
monkeypatch.delenv("USERPROFILE", raising=False)
|
||
|
||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
||
assert not allowed, f"[{simulated_os}] команда прошла мимо защиты: {cmd} ({reason})"
|
||
|
||
|
||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
||
def test_guard_fails_closed_on_unresolvable_argument(monkeypatch, guard_env, simulated_os):
|
||
"""Нераскрытая переменная — отказ, а не пропуск.
|
||
|
||
Раньше "$HOME/.hermes" с неизвестной переменной переставал быть абсолютным
|
||
путём, склеивался с каталогом проекта и признавался допустимым. Путь,
|
||
который нельзя разрешить, нельзя и признать безопасным.
|
||
"""
|
||
if simulated_os == "windows":
|
||
monkeypatch.delenv("HOME", raising=False)
|
||
monkeypatch.delenv("HERMES_UNSET_TARGET", raising=False)
|
||
|
||
for cmd in ["rm -rf $HERMES_UNSET_TARGET/data", r"rm -rf %HERMES_UNSET_TARGET%\data"]:
|
||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
||
assert not allowed, f"[{simulated_os}] guard не закрылся на нераскрытом пути: {cmd} ({reason})"
|
||
|
||
|
||
def test_foreign_dialect_absolute_path_is_not_treated_as_project_local(guard_env):
|
||
"""Путь с буквой диска на Linux не должен считаться внутренним.
|
||
|
||
Path("C:/Windows").resolve() на Linux приписывает пути текущий каталог, и
|
||
удаление системного каталога Windows выглядело как работа внутри проекта.
|
||
"""
|
||
allowed, reason, _alt = guard_env.validate_command(r"del /f /q C:\Windows\System32")
|
||
assert not allowed, f"путь чужого диалекта признан внутренним: {reason}"
|
||
|
||
|
||
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} отклонён межсайтовой защитой"
|