* fix(security): the first tirith scan no longer waits on its download
A process that never ran the CLI startup install (desktop serve, the
gateway, a newly routed profile) claimed the install on its first scan and
ran pm.ensure("tirith") inline. The first terminal call then waited on the
PM worker, the runtime toolchain and the tirith download before it ran.
The cold scan now starts the same background install as startup and scans
with the default path, so the call fails open at once like any scan during
a startup download.
* fix(security): a scan during the tirith download never trips the breaker
The previous commit let a cold scan run while the background install was
still downloading. That scan spawned the bare default "tirith", got
FileNotFoundError and counted it as a crash. Three terminal calls during
the download opened the circuit breaker, and every scan then returned
allow unscanned for 300 s, even after the download finished. With
tirith_fail_open: false the first three commands were blocked and the
breaker then let commands run unscanned.
A scan that finds the default binary missing while this home's install
thread is alive no longer spawns or counts a crash. Fail-open allows the
command at once ("tirith installing"). Fail-closed waits for the install,
as the inline install did before, and then scans with the real binary.
So only the fail-open case skips the scan during a download; the earlier
commit's "fails open at once" holds only for fail_open.
The PM consumer test now joins the background install before it reads
the recorded download requests.
537 lines
25 KiB
Python
537 lines
25 KiB
Python
"""Tests for the tirith security scanning subprocess wrapper."""
|
||
|
||
import json
|
||
import subprocess
|
||
import time
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
import tools.tirith_security as _tirith_mod
|
||
from tools.tirith_security import check_command_security, ensure_installed
|
||
|
||
|
||
def _reset_state():
|
||
_tirith_mod._install_attempted.clear()
|
||
_tirith_mod._install_threads.clear()
|
||
_tirith_mod._crash_count = 0
|
||
_tirith_mod._circuit_open = False
|
||
_tirith_mod._circuit_open_at = 0.0
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_tirith_state():
|
||
_reset_state()
|
||
yield
|
||
_reset_state()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _mock_run(returncode=0, stdout="", stderr=""):
|
||
"""Build a mock subprocess.CompletedProcess."""
|
||
cp = MagicMock(spec=subprocess.CompletedProcess)
|
||
cp.returncode = returncode
|
||
cp.stdout = stdout
|
||
cp.stderr = stderr
|
||
return cp
|
||
|
||
|
||
def _json_stdout(findings=None, summary=""):
|
||
return json.dumps({"findings": findings or [], "summary": summary})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Exit code → action mapping
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestExitCodeMapping:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_0_allow(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(0, _json_stdout())
|
||
result = check_command_security("echo hello")
|
||
assert result["action"] == "allow"
|
||
assert result["findings"] == []
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_1_block_with_findings(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
findings = [{"rule_id": "homograph_url", "severity": "high"}]
|
||
mock_run.return_value = _mock_run(1, _json_stdout(findings, "homograph detected"))
|
||
result = check_command_security("curl http://gооgle.com")
|
||
assert result["action"] == "block"
|
||
assert len(result["findings"]) == 1
|
||
assert result["summary"] == "homograph detected"
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_2_warn_with_findings(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
findings = [{"rule_id": "shortened_url", "severity": "medium"}]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "shortened URL"))
|
||
result = check_command_security("curl https://bit.ly/abc")
|
||
assert result["action"] == "warn"
|
||
assert len(result["findings"]) == 1
|
||
assert result["summary"] == "shortened URL"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# JSON parse failure (exit code still wins)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestJsonParseFailure:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_1_invalid_json_still_blocks(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(1, "NOT JSON")
|
||
result = check_command_security("bad command")
|
||
assert result["action"] == "block"
|
||
assert "details unavailable" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_0_invalid_json_allows(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(0, "NOT JSON")
|
||
result = check_command_security("safe command")
|
||
assert result["action"] == "allow"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Operational failures + fail_open
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestOSErrorFailOpen:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_file_not_found_fail_open(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||
result = check_command_security("echo hi")
|
||
assert result["action"] == "allow"
|
||
assert "unavailable" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_os_error_fail_closed(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||
result = check_command_security("echo hi")
|
||
assert result["action"] == "block"
|
||
assert "fail-closed" in result["summary"]
|
||
|
||
|
||
class TestTimeoutFailOpen:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_timeout_fail_closed(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5)
|
||
result = check_command_security("slow command")
|
||
assert result["action"] == "block"
|
||
assert "fail-closed" in result["summary"]
|
||
|
||
|
||
class TestUnknownExitCode:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_unknown_exit_code_fail_closed(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||
mock_run.return_value = _mock_run(99, "")
|
||
result = check_command_security("cmd")
|
||
assert result["action"] == "block"
|
||
assert "exit code 99" in result["summary"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Circuit breaker: half-open recovery
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _open_breaker(age_s):
|
||
"""Put the breaker in the open state as if it tripped ``age_s`` seconds ago."""
|
||
_tirith_mod._crash_count = _tirith_mod._CRASH_LIMIT
|
||
_tirith_mod._circuit_open = True
|
||
_tirith_mod._circuit_open_at = time.monotonic() - age_s
|
||
|
||
|
||
class TestCircuitBreakerHalfOpen:
|
||
@pytest.mark.parametrize("returncode, action", [(0, "allow"), (1, "block"), (2, "warn")])
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_completed_probe_after_retry_window_closes_breaker(self, mock_cfg, mock_run, returncode, action):
|
||
"""Once the retry window has elapsed, one real scan runs; any verdict (allow/block/warn)
|
||
proves the binary healthy and closes the breaker, so the next command is scanned again."""
|
||
mock_cfg.return_value = _CFG
|
||
_open_breaker(age_s=_tirith_mod._CIRCUIT_RETRY_S + 1)
|
||
mock_run.return_value = _mock_run(returncode, _json_stdout())
|
||
|
||
result = check_command_security("echo hi")
|
||
|
||
assert result["action"] == action
|
||
assert mock_run.call_count == 1
|
||
assert (_tirith_mod._circuit_open, _tirith_mod._crash_count) == (False, 0)
|
||
# Breaker closed: the following command is scanned rather than short-circuited.
|
||
check_command_security("echo again")
|
||
assert mock_run.call_count == 2
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_open_breaker_probes_once_per_window_and_failed_probe_rearms(self, mock_cfg, mock_run):
|
||
"""Inside the window nothing spawns; after it exactly one probe runs, and a probe that
|
||
fails re-arms the window so the next caller is fail-open without spawning again."""
|
||
mock_cfg.return_value = _CFG
|
||
_open_breaker(age_s=1)
|
||
mock_run.side_effect = OSError("binary gone")
|
||
|
||
assert check_command_security("echo hi")["summary"] == "tirith disabled (circuit breaker)"
|
||
assert mock_run.call_count == 0
|
||
|
||
_open_breaker(age_s=_tirith_mod._CIRCUIT_RETRY_S + 1)
|
||
assert check_command_security("echo hi")["action"] == "allow" # probe spawned and failed
|
||
assert mock_run.call_count == 1
|
||
assert _tirith_mod._circuit_open is True
|
||
assert check_command_security("echo hi")["summary"] == "tirith disabled (circuit breaker)"
|
||
assert mock_run.call_count == 1 # re-armed: no second probe inside the fresh window
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Disabled
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDisabled:
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_disabled_returns_allow(self, mock_cfg):
|
||
mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
result = check_command_security("rm -rf /")
|
||
assert result["action"] == "allow"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Findings cap + summary cap
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestCaps:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_findings_and_summary_capped(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
findings = [{"rule_id": f"rule_{i}"} for i in range(100)]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "x" * 1000))
|
||
result = check_command_security("cmd")
|
||
assert len(result["findings"]) == 50
|
||
assert len(result["summary"]) == 500
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Programming errors propagate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestProgrammingErrors:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_attribute_error_propagates(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = AttributeError("unexpected bug")
|
||
with pytest.raises(AttributeError):
|
||
check_command_security("cmd")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ensure_installed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestEnsureInstalled:
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_disabled_returns_none(self, mock_cfg):
|
||
mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
assert ensure_installed() is None
|
||
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_found_on_path_returns_immediately(self, mock_cfg, mock_which):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
assert ensure_installed() == "/usr/local/bin/tirith"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unsupported platform (Windows etc.) — silent fast-path everywhere
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestUnsupportedPlatform:
|
||
"""When PM has no tirith build for this OS+arch, the entire subsystem
|
||
must stay silent: no install thread, no spawn attempts, no CLI banner. Pattern-matching
|
||
guards still cover the gap; tirith content scanning is just absent."""
|
||
|
||
@pytest.mark.parametrize("target, expected", [
|
||
("linux-x64", True),
|
||
("win32-x64", False),
|
||
(RuntimeError("unsupported architecture: riscv64"), False),
|
||
])
|
||
def test_is_platform_supported(self, target, expected):
|
||
# Table inputs, not a host fake: support is PM's per-target mapping.
|
||
current_target = MagicMock(side_effect=[target])
|
||
with patch("pm.current_target", current_target):
|
||
assert _tirith_mod.is_platform_supported() is expected
|
||
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_check_command_security_unsupported_allows_silently(self, mock_cfg):
|
||
"""Windows: skip the resolver and spawn entirely — return allow with
|
||
an empty summary so callers can't accidentally surface 'tirith
|
||
unavailable' messaging to the user."""
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||
patch("tools.tirith_security.subprocess.run") as mock_run, \
|
||
patch("tools.tirith_security._resolve_tirith_path") as mock_resolve:
|
||
result = check_command_security("rm -rf /")
|
||
assert result == {"action": "allow", "findings": [], "summary": ""}
|
||
mock_run.assert_not_called()
|
||
mock_resolve.assert_not_called()
|
||
|
||
def test_explicit_path_still_honored_on_unsupported_platform(self, tmp_path):
|
||
"""If a user explicitly configured a tirith_path (e.g. they built it
|
||
themselves under WSL), the unsupported-platform short-circuit must
|
||
NOT override that — explicit config wins."""
|
||
custom = tmp_path / "tirith"
|
||
custom.write_text("#!/bin/sh\nexit 0\n")
|
||
custom.chmod(0o755)
|
||
with patch("tools.tirith_security.is_platform_supported", return_value=False):
|
||
assert _tirith_mod._resolve_tirith_path(str(custom)) == str(custom)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PM-provisioned binary: one install attempt per home, explicit paths never download
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_BARE_CFG = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
|
||
|
||
@pytest.fixture
|
||
def pm_tirith(monkeypatch):
|
||
"""Nothing on PATH, nothing installed yet, lazy installs allowed."""
|
||
import pm
|
||
|
||
installed = MagicMock(return_value=None)
|
||
ensure = MagicMock()
|
||
monkeypatch.setattr("tools.tirith_security.shutil.which", lambda _name: None)
|
||
monkeypatch.setattr(pm, "installed_package", installed)
|
||
monkeypatch.setattr(pm, "ensure", ensure)
|
||
monkeypatch.setattr(pm, "lazy_installs_allowed", lambda: True)
|
||
return ensure, installed
|
||
|
||
|
||
class TestPmInstall:
|
||
def test_default_path_installs_through_pm(self, pm_tirith):
|
||
"""The default bare 'tirith' is provisioned by PM on a cold scan."""
|
||
ensure, installed = pm_tirith
|
||
ensure.side_effect = lambda *_a, **_k: setattr(
|
||
installed, "return_value", MagicMock(binary="/pm/tirith"))
|
||
|
||
assert _tirith_mod._resolve_tirith_path("tirith") == "tirith"
|
||
for thread in _tirith_mod._install_threads.values():
|
||
thread.join(5)
|
||
ensure.assert_called_once_with("tirith")
|
||
assert _tirith_mod._resolve_tirith_path("tirith") == "/pm/tirith"
|
||
|
||
def test_failed_install_is_not_retried(self, pm_tirith):
|
||
"""After a failed install, subsequent resolves fall back without retrying."""
|
||
ensure, _ = pm_tirith
|
||
ensure.side_effect = RuntimeError("download failed")
|
||
|
||
assert _tirith_mod._resolve_tirith_path("tirith") == "tirith"
|
||
for thread in _tirith_mod._install_threads.values():
|
||
thread.join(5)
|
||
assert _tirith_mod._resolve_tirith_path("tirith") == "tirith"
|
||
assert ensure.call_count == 1
|
||
|
||
def test_tilde_explicit_path_missing_no_download(self, pm_tirith):
|
||
"""An explicit ~/path that doesn't exist must NOT trigger an install."""
|
||
ensure, _ = pm_tirith
|
||
|
||
result = _tirith_mod._resolve_tirith_path("~/bin/tirith")
|
||
|
||
ensure.assert_not_called()
|
||
assert "~" not in result # tilde still expanded
|
||
|
||
def test_install_proceeds_without_cosign(self, tmp_path):
|
||
"""Provenance is optional without cosign: SHA-256 verification alone proceeds."""
|
||
with patch("tools.tirith_security.shutil.which", return_value=None):
|
||
verified, reason = _tirith_mod.verify_release_provenance(tmp_path, MagicMock())
|
||
assert (verified, reason) == (False, "")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Background install / non-blocking startup (P2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestBackgroundInstall:
|
||
def test_ensure_installed_non_blocking(self, pm_tirith):
|
||
"""ensure_installed must return immediately when an install is needed."""
|
||
with patch("tools.tirith_security._load_security_config", return_value=_BARE_CFG), \
|
||
patch("tools.tirith_security.is_platform_supported", return_value=True), \
|
||
patch("tools.tirith_security.threading.Thread") as MockThread:
|
||
assert ensure_installed() is None # not available yet
|
||
MockThread.assert_called_once()
|
||
MockThread.return_value.start.assert_called_once()
|
||
|
||
def test_scan_does_not_wait_on_startup_install(self, pm_tirith):
|
||
"""A scan during the startup install returns the default instead of installing again."""
|
||
ensure, _ = pm_tirith
|
||
with patch("tools.tirith_security._load_security_config", return_value=_BARE_CFG), \
|
||
patch("tools.tirith_security.is_platform_supported", return_value=True), \
|
||
patch("tools.tirith_security.threading.Thread"):
|
||
ensure_installed()
|
||
|
||
assert _tirith_mod._resolve_tirith_path("tirith") == "tirith"
|
||
ensure.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Warn-once dedupe (issue: tirith spawn failed spamming on Windows)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSpawnWarningDedup:
|
||
"""When tirith isn't installed yet (background install in flight, or
|
||
install marked failed), every terminal command spammed an identical
|
||
``tirith spawn failed: [WinError 2]`` warning to ``errors.log``. The
|
||
dedupe set in ``_warn_once`` collapses repeats by ``(exc class, errno)``
|
||
while still surfacing the first occurrence so users see the failure.
|
||
"""
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_repeated_spawn_failure_logs_once(self, mock_cfg, mock_run, caplog):
|
||
mock_cfg.return_value = {
|
||
"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True,
|
||
}
|
||
mock_run.side_effect = FileNotFoundError("[WinError 2]")
|
||
# Fresh dedupe state — clear any keys left by other tests.
|
||
_tirith_mod._warned_messages.clear()
|
||
|
||
with caplog.at_level("WARNING", logger="tools.tirith_security"):
|
||
for i in range(15):
|
||
result = check_command_security("echo hi")
|
||
# Behavior must remain the same on every call —
|
||
# fail-open allow, with the exception captured in summary.
|
||
assert result["action"] == "allow"
|
||
if i < _tirith_mod._CRASH_LIMIT:
|
||
# Before circuit breaker opens, summary has the exception
|
||
assert "unavailable" in result["summary"]
|
||
else:
|
||
# After circuit breaker opens, summary is generic
|
||
assert "circuit breaker" in result["summary"]
|
||
|
||
spawn_warnings = [
|
||
rec for rec in caplog.records
|
||
if "tirith spawn failed" in rec.message
|
||
]
|
||
assert len(spawn_warnings) == 1, (
|
||
f"expected exactly 1 spawn-failed warning across 15 commands, "
|
||
f"got {len(spawn_warnings)}: {[r.message for r in spawn_warnings]}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# .app TLD suppression (issue #24461)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_CFG = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
|
||
|
||
class TestAppTldSuppression:
|
||
"""warn verdicts whose only finding is lookalike_tld/.app are downgraded to allow."""
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_app_only_warn_downgraded_to_allow(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = _CFG
|
||
findings = [{"rule_id": "lookalike_tld", "value": ".app",
|
||
"message": "Domain uses '.app' TLD which can be confused with file extensions"}]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, ".app TLD warning"))
|
||
result = check_command_security("curl https://example.app")
|
||
assert result["action"] == "allow"
|
||
assert result["findings"] == []
|
||
assert result["summary"] == ""
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_mixed_findings_preserve_warn(self, mock_cfg, mock_run):
|
||
"""If .app finding is accompanied by another finding, warn is preserved."""
|
||
mock_cfg.return_value = _CFG
|
||
findings = [
|
||
{"rule_id": "lookalike_tld", "value": ".app"},
|
||
{"rule_id": "shortened_url", "severity": "medium"},
|
||
]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "mixed"))
|
||
result = check_command_security("curl https://bit.ly/test.app")
|
||
assert result["action"] == "warn"
|
||
assert len(result["findings"]) == 2
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_block_verdict_never_suppressed(self, mock_cfg, mock_run):
|
||
"""block exit code is never downgraded, even if finding looks like .app."""
|
||
mock_cfg.return_value = _CFG
|
||
findings = [{"rule_id": "lookalike_tld", "value": ".app"}]
|
||
mock_run.return_value = _mock_run(1, _json_stdout(findings, "block"))
|
||
result = check_command_security("curl https://example.app")
|
||
assert result["action"] == "block"
|
||
|
||
|
||
class TestEmojiVariationSelectorSuppression:
|
||
"""VS16 after an emoji-capable base is presentation, not obfuscation: no approval prompt."""
|
||
|
||
_VS = [{"rule_id": "variation_selector", "severity": "medium"}]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_emoji_only_variation_selector_warn_is_downgraded(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = _CFG
|
||
mock_run.return_value = _mock_run(2, _json_stdout(self._VS, "variation selector"))
|
||
|
||
# SMP emoji, Dingbats/Misc Symbols, and BMP singletons outside those blocks (ℹ ▶).
|
||
result = check_command_security('ls "🗞️ Journal/" "✅️ Projects/" "ℹ️ Info/" "▶️ Media/"')
|
||
|
||
assert result == {"action": "allow", "findings": [], "summary": ""}
|
||
|
||
@pytest.mark.parametrize("command, findings", [
|
||
("printf 'a️'", _VS), # VS16 after a letter
|
||
("printf '0️'", _VS), # VS16 after a digit (keycap base)
|
||
("printf 'x󠄀'", _VS), # a non-VS16 selector
|
||
('curl https://bit.ly/x --output "🗞️ Journal/file"', # emoji path + another finding
|
||
_VS + [{"rule_id": "shortened_url", "severity": "medium"}]),
|
||
])
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_other_selectors_or_mixed_findings_keep_warn(self, mock_cfg, mock_run, command, findings):
|
||
mock_cfg.return_value = _CFG
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "variation selector"))
|
||
|
||
result = check_command_security(command)
|
||
|
||
assert result["action"] == "warn"
|
||
assert result["findings"] == findings
|