test_canonical_security.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. from __future__ import annotations
  2. from datetime import UTC, datetime
  3. from decimal import Decimal
  4. from pathlib import Path
  5. import pytest
  6. from pydantic import ValidationError
  7. from script_build_host.domain.digests import Sha256Digest
  8. from script_build_host.domain.errors import ProtocolViolation, UnsafeOutboundTarget
  9. from script_build_host.infrastructure.canonical_json import canonical_json_text, canonical_sha256
  10. from script_build_host.infrastructure.config import PROJECT_ROOT, ScriptBuildSettings
  11. from script_build_host.infrastructure.outbound import OutboundPolicy
  12. from script_build_host.infrastructure.redaction import redact, redact_text
  13. def test_canonical_json_is_stable_and_preserves_array_order() -> None:
  14. first = {
  15. "z": 1.2300,
  16. "when": datetime(2026, 7, 19, 4, 5, 6, 7, tzinfo=UTC),
  17. "items": ["b", "a"],
  18. "amount": Decimal("2.500"),
  19. "empty": [],
  20. "missing": None,
  21. }
  22. second = dict(reversed(tuple(first.items())))
  23. text = canonical_json_text(first)
  24. assert text == canonical_json_text(second)
  25. assert '"items":["b","a"]' in text
  26. assert '"z":"1.23"' in text
  27. assert '"amount":"2.5"' in text
  28. assert canonical_sha256(first) == canonical_sha256(second)
  29. @pytest.mark.parametrize(
  30. "value",
  31. ["SHA256:" + "0" * 64, "sha256:" + "A" * 64, "md5:" + "0" * 64, "0" * 64],
  32. )
  33. def test_digest_rejects_noncanonical_wire_forms(value: str) -> None:
  34. with pytest.raises(ProtocolViolation):
  35. Sha256Digest.from_wire(value)
  36. def test_redaction_removes_secrets_from_nested_values() -> None:
  37. value = {
  38. "authorization": "Bearer raw-token",
  39. "database_url": "mysql://reader:secret@db.example/app",
  40. "nested": {"url": "https://api.example/search?token=abc&q=safe"},
  41. }
  42. result = redact(value)
  43. assert "raw-token" not in str(result)
  44. assert "secret" not in str(result)
  45. assert "abc" not in str(result)
  46. assert redact_text("Bearer abc") == "Bearer [REDACTED]"
  47. def _settings(tmp_path: Path, **overrides: object) -> ScriptBuildSettings:
  48. values: dict[str, object] = {
  49. "environment": "test",
  50. "read_database_url": "sqlite+aiosqlite:///read.db",
  51. "write_database_url": "sqlite+aiosqlite:///write.db",
  52. "agent_data_root": tmp_path,
  53. "persona_root": tmp_path,
  54. "section_pattern_root": tmp_path,
  55. "decode_index_root": tmp_path,
  56. "knowledge_root": tmp_path,
  57. "prompt_fallback_root": tmp_path,
  58. }
  59. values.update(overrides)
  60. return ScriptBuildSettings(**values) # type: ignore[arg-type]
  61. def test_settings_hide_dsns_and_enforce_production_user_separation(tmp_path: Path) -> None:
  62. settings = _settings(tmp_path)
  63. assert "sqlite" not in repr(settings)
  64. with pytest.raises(ValidationError, match="must be different"):
  65. _settings(
  66. tmp_path,
  67. environment="production",
  68. read_database_url="mysql+asyncmy://same:read@db.example/app",
  69. write_database_url="mysql+asyncmy://same:write@db.example/app",
  70. )
  71. def test_settings_resolve_relative_paths_from_project_root(
  72. tmp_path: Path, monkeypatch: pytest.MonkeyPatch
  73. ) -> None:
  74. monkeypatch.chdir(tmp_path)
  75. settings = _settings(
  76. Path(".local/agent-data"),
  77. persona_root=Path(".local/legacy-inputs/persona"),
  78. section_pattern_root=Path(".local/legacy-inputs/sections"),
  79. decode_index_root=Path(".local/legacy-inputs/decode/index"),
  80. decode_raw_root=Path(".local/legacy-inputs/decode/raw"),
  81. knowledge_root=Path(".local/legacy-inputs/knowledge/knowledge.json"),
  82. prompt_fallback_root=Path("src/script_build_host/agents/prompts"),
  83. )
  84. assert settings.agent_data_root == PROJECT_ROOT / ".local/agent-data"
  85. assert settings.persona_root == PROJECT_ROOT / ".local/legacy-inputs/persona"
  86. assert settings.decode_raw_root == PROJECT_ROOT / ".local/legacy-inputs/decode/raw"
  87. assert settings.prompt_fallback_root == PROJECT_ROOT / "src/script_build_host/agents/prompts"
  88. @pytest.mark.asyncio
  89. async def test_outbound_policy_revalidates_dns_and_redirects() -> None:
  90. calls: list[tuple[str, int]] = []
  91. async def resolver(host: str, port: int) -> tuple[str, ...]:
  92. calls.append((host, port))
  93. return ("93.184.216.34",)
  94. policy = OutboundPolicy(frozenset({"api.example"}), resolver=resolver)
  95. assert await policy.validate_url("https://api.example/path") == "https://api.example/path"
  96. assert (
  97. await policy.validate_redirect("https://api.example/path", "/next")
  98. == "https://api.example/next"
  99. )
  100. assert calls == [("api.example", 443), ("api.example", 443)]
  101. @pytest.mark.asyncio
  102. async def test_outbound_policy_rejects_private_dns_and_non_allowlisted_redirect() -> None:
  103. async def private_resolver(_host: str, _port: int) -> tuple[str, ...]:
  104. return ("169.254.169.254",)
  105. policy = OutboundPolicy(frozenset({"api.example"}), resolver=private_resolver)
  106. with pytest.raises(UnsafeOutboundTarget):
  107. await policy.validate_url("https://api.example/latest/meta-data")
  108. async def public_resolver(_host: str, _port: int) -> tuple[str, ...]:
  109. return ("93.184.216.34",)
  110. public_policy = OutboundPolicy(frozenset({"api.example"}), resolver=public_resolver)
  111. with pytest.raises(UnsafeOutboundTarget):
  112. await public_policy.validate_redirect("https://api.example/path", "https://evil.example/")