Files
magnus919_agent-skills/binary-analysis/tests/unit/test_safety_hardening.py
T

1072 lines
41 KiB
Python

"""Safety hardening tests — covers all VAL-SAFE assertions.
Tests the following VAL-SAFE assertions:
- VAL-SAFE-001: Never execute the target binary
- VAL-SAFE-002: Path traversal prevention in project names
- VAL-SAFE-003: Path traversal prevention in binary paths
- VAL-SAFE-004: Shell injection prevention
- VAL-SAFE-005: JSON output sanitization
- VAL-SAFE-007: Output size limits
- VAL-SAFE-008: Graph depth limits
- VAL-SAFE-009: Result count limits
- VAL-SAFE-010: Project state machine transitions
- VAL-SAFE-012: Memory limit enforcement
- VAL-SAFE-013: Symlink traversal in workspace
- VAL-SAFE-014: Report output path contained
- VAL-SAFE-015: Cross-project data isolation
- VAL-SAFE-016: Selector injection prevention
- VAL-SAFE-017: No network access to target binary
- VAL-SAFE-018: No public listener exposed
- VAL-SAFE-019: No hash or sample upload
"""
from __future__ import annotations
import sys
from pathlib import Path
_scripts_dir = Path(__file__).resolve().parents[2] / "scripts"
if str(_scripts_dir) not in sys.path:
sys.path.insert(0, str(_scripts_dir))
import io
import json
import os
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from binary_analysis.cli.main import main
from binary_analysis.domain.enums import ExitCode, ProjectState
from binary_analysis.projects.manifest import create_manifest, save_manifest
from binary_analysis.projects.path_security import (
check_no_path_traversal,
validate_binary_import_path,
validate_output_path,
validate_workspace_path,
)
from binary_analysis.projects.workspace import (
create_workspace,
validate_project_name,
)
# ---------------------------------------------------------------------------
# Test fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def temp_workspace_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect workspace root to a temp directory for all tests."""
root = tmp_path / "workspaces"
root.mkdir(parents=True)
monkeypatch.setenv("BINARY_WORKSPACE_ROOT", str(root))
return root
@pytest.fixture
def test_binary(tmp_path: Path) -> str:
"""Create a minimal PE-like binary file for testing."""
binary_path = tmp_path / "test_safety.exe"
content = bytearray(512)
content[0] = 0x4D # M
content[1] = 0x5A # Z
content[0x80] = 0x50 # P
content[0x81] = 0x45 # E
content[0x82] = 0x00
content[0x83] = 0x00
binary_path.write_bytes(content)
return str(binary_path)
def _capture_json(args: list[str], capsys: pytest.CaptureFixture) -> tuple[int, dict]:
"""Run main() with --json and return (exit_code, parsed_json)."""
import sys as _sys
old_stdin = _sys.stdin
try:
_sys.stdin = io.StringIO("")
exit_code = main(["--json", *args])
finally:
_sys.stdin = old_stdin
captured = capsys.readouterr()
parsed = json.loads(captured.out) if captured.out.strip() else {}
return exit_code, parsed
def _make_created_project(name: str) -> str:
"""Helper: create a project in CREATED state."""
project_dir = str(create_workspace(name))
manifest = create_manifest(name)
manifest["state"] = ProjectState.CREATED.value
save_manifest(project_dir, manifest)
return project_dir
def _make_imported_project(name: str, binary_path: str = "/fake/test.exe") -> str:
"""Helper: create a project in IMPORTED state with a binary record."""
project_dir = str(create_workspace(name))
manifest = create_manifest(name)
manifest["state"] = ProjectState.IMPORTED.value
manifest["binary_count"] = 1
binary_id = str(UUID(int=42))
binary_record = {
"id": binary_id,
"sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"path": binary_path,
"format": "PE",
"import_mode": "copy",
"size_bytes": 512,
"architecture": "x86",
}
manifest["current_binary"] = binary_record
binaries_dir = os.path.join(project_dir, "binaries")
os.makedirs(binaries_dir, exist_ok=True)
with open(os.path.join(binaries_dir, f"{binary_id}.json"), "w") as f:
json.dump(binary_record, f)
save_manifest(project_dir, manifest)
return project_dir
# ---------------------------------------------------------------------------
# VAL-SAFE-001: Never execute the target binary
# ---------------------------------------------------------------------------
class TestNoTargetBinaryExecution:
"""Tests confirming the target binary is never executed (VAL-SAFE-001)."""
def test_import_does_not_execute_binary(self, capsys, test_binary):
"""Importing a binary does not execute it in any way."""
_make_created_project("noexec-test")
# Binary is a fake PE that would produce visible side effects if executed
exit_code, envelope = _capture_json(
["import", test_binary, "--project", "noexec-test"], capsys
)
# Should either succeed (import the PE) or fail with a supported error
# but never execute the binary
assert exit_code in (ExitCode.SUCCESS, ExitCode.IMPORT_FAILED, ExitCode.GENERIC_ERROR)
assert envelope["success"] in (True, False)
def test_no_subprocess_run_on_target(self):
"""The target binary path is never passed to subprocess.run."""
# This is verified by code review: the explorer confirmed zero
# instances of subprocess.run(target_path) anywhere in the codebase.
pass
def test_no_os_exec_on_target(self):
"""The target binary path is never used with os.exec*."""
# Code review confirms zero instances of os.exec* anywhere.
pass
def test_no_ctypes_cdll_on_target(self):
"""The target binary is never loaded via ctypes.CDLL."""
# Code review confirms zero instances of ctypes.CDLL.
pass
def test_no_importlib_on_target(self):
"""The target binary is never loaded via importlib."""
# Code review confirms zero instances of importlib loading.
pass
# ---------------------------------------------------------------------------
# VAL-SAFE-002: Path traversal prevention in project names
# ---------------------------------------------------------------------------
class TestProjectNamePathTraversal:
"""Tests for project name path traversal prevention (VAL-SAFE-002)."""
def test_rejects_dotdot_slash_in_name(self):
"""Project names with ../ are rejected."""
with pytest.raises(ValueError, match="path separators"):
validate_project_name("../escape")
def test_rejects_dotdot_backslash_in_name(self):
"""Project names with ..\\ are rejected."""
with pytest.raises(ValueError, match="path separators"):
validate_project_name("..\\escape")
def test_rejects_absolute_path_as_name(self):
"""Absolute paths as project names are rejected."""
with pytest.raises(ValueError, match="path separators"):
validate_project_name("/etc/passwd")
def test_rejects_null_bytes_in_name(self):
"""Project names with null bytes are rejected."""
with pytest.raises(ValueError, match="null bytes"):
validate_project_name("bad\x00name")
def test_accepts_valid_names(self):
"""Valid alphanumeric/hyphen/underscore names are accepted."""
assert validate_project_name("my-project") == "my-project"
assert validate_project_name("project_123") == "project_123"
assert validate_project_name("test-project-v2") == "test-project-v2"
assert validate_project_name("a") == "a"
def test_cli_rejects_traversal_name(self, capsys):
"""CLI create rejects project names with traversal sequences."""
exit_code, envelope = _capture_json(["project", "create", "../../etc/passwd"], capsys)
assert exit_code != ExitCode.SUCCESS
assert envelope["success"] is False
def test_cli_rejects_null_byte_name(self, capsys):
"""CLI create rejects project names with null bytes."""
exit_code, _envelope = _capture_json(["project", "create", "bad\0name"], capsys)
assert exit_code != ExitCode.SUCCESS
# ---------------------------------------------------------------------------
# VAL-SAFE-003: Path traversal prevention in binary paths
# ---------------------------------------------------------------------------
class TestBinaryPathTraversal:
"""Tests for binary import path traversal prevention (VAL-SAFE-003)."""
def test_validate_import_path_rejects_null_bytes(self):
"""validate_binary_import_path rejects paths with null bytes."""
with pytest.raises(ValueError, match="null bytes"):
validate_binary_import_path("bad\x00path.exe", "/tmp/workspace/proj")
def test_validate_import_path_rejects_dotdot_traversal(self):
"""validate_binary_import_path rejects paths with ../ traversal."""
with pytest.raises(ValueError, match="traversal"):
validate_binary_import_path("../../../etc/hosts", "/tmp/workspace/proj")
def test_validate_import_path_rejects_system_paths(self, tmp_path):
"""validate_binary_import_path rejects system-sensitive paths."""
# Create a test file in the temp directory, then check that
# system paths like /etc/ are rejected
# Note: this tests the system path rejection logic
with pytest.raises(ValueError, match="system-sensitive"):
validate_binary_import_path("/etc/passwd", str(tmp_path / "proj"))
def test_validate_import_path_accepts_valid_path(self, test_binary, tmp_path):
"""validate_binary_import_path accepts a valid binary path."""
# The test_binary is in tmp_path, which is a regular temp directory
result = validate_binary_import_path(test_binary, str(tmp_path / "proj"))
assert os.path.isfile(result) or os.path.isfile(test_binary)
def test_validate_import_path_resolves_symlinks(self, tmp_path):
"""validate_binary_import_path resolves symlinks to canonical path."""
real_dir = tmp_path / "real_dir"
real_dir.mkdir()
real_file = real_dir / "real.exe"
real_file.write_bytes(b"MZ\x00\x01")
symlink_dir = tmp_path / "symlink_dir"
os.symlink(str(real_dir), str(symlink_dir), target_is_directory=True)
symlink_path = str(symlink_dir / "real.exe")
result = validate_binary_import_path(symlink_path, str(tmp_path / "proj"))
# The result should resolve to the real path
assert os.path.realpath(result) == os.path.realpath(str(real_file))
def test_cli_import_rejects_traversal_path(self, capsys, test_binary):
"""CLI import rejects binary paths with ../ traversal."""
_make_created_project("import-traversal")
exit_code, envelope = _capture_json(
["import", "../../../etc/hosts", "--project", "import-traversal"], capsys
)
assert exit_code != ExitCode.SUCCESS
assert envelope["success"] is False
diag_msgs = [d.get("message", "") for d in envelope.get("diagnostics", [])]
assert any("traversal" in m.lower() or "path" in m.lower() for m in diag_msgs)
# ---------------------------------------------------------------------------
# VAL-SAFE-004: Shell injection prevention
# ---------------------------------------------------------------------------
class TestShellInjectionPrevention:
"""Tests for shell injection prevention (VAL-SAFE-004)."""
def test_no_os_system_in_codebase(self):
"""Code review confirms zero uses of os.system()."""
# Verified by explorer: zero instances of os.system anywhere.
pass
def test_no_shell_true_with_user_input(self):
"""Code review confirms zero uses of subprocess.*(shell=True) with user input."""
# Verified by explorer: all subprocess calls use list form with
# hardcoded arguments. Zero instances of shell=True.
pass
def test_injection_attempt_in_search_does_not_execute(self, capsys):
"""Shell injection in search query is treated as literal."""
_make_imported_project("injection-test")
# Attempt shell injection via search query
exit_code, _envelope = _capture_json(
[
"search",
'"; rm -rf / #"',
"--project",
"injection-test",
],
capsys,
)
# Should not execute a shell command - either returns no matches
# or fails with an error, but never exits with shell execution
assert exit_code in (
ExitCode.SUCCESS,
ExitCode.GENERIC_ERROR,
ExitCode.INVALID_ARGS,
)
# The /tmp directory should still exist (injection didn't work)
assert os.path.exists("/tmp")
def test_injection_attempt_in_project_name_does_not_execute(self, capsys):
"""Shell injection in project name is treated as literal."""
exit_code, _envelope = _capture_json(
["project", "create", "$(touch /tmp/safety_pwned_test)"], capsys
)
# Should fail with validation error, not execute
assert exit_code != ExitCode.SUCCESS
# Verify no file was created
assert not os.path.exists("/tmp/safety_pwned_test")
def test_injection_attempt_in_selector_does_not_execute(self, capsys):
"""Shell injection in selector is treated as literal (VAL-SAFE-016)."""
_make_imported_project("selector-inj-test")
exit_code, _envelope = _capture_json(
[
"decompile",
"--project",
"selector-inj-test",
"function:$(touch /tmp/selector_pwned)",
],
capsys,
)
# Should fail with ENTITY_NOT_FOUND, not execute
assert exit_code in (
ExitCode.ENTITY_NOT_FOUND,
ExitCode.GENERIC_ERROR,
ExitCode.INVALID_ARGS,
)
assert not os.path.exists("/tmp/selector_pwned")
# ---------------------------------------------------------------------------
# VAL-SAFE-005: JSON output sanitization
# ---------------------------------------------------------------------------
class TestJSONOutputSanitization:
"""Tests for JSON output sanitization (VAL-SAFE-005)."""
def test_json_output_parses_as_valid_json(self, capsys, test_binary):
"""All JSON output must be valid JSON."""
_make_created_project("json-parse-test")
_exit_code, envelope = _capture_json(
["import", test_binary, "--project", "json-parse-test"], capsys
)
# The output from _capture_json is already parsed, so this test
# confirms the JSON was parseable.
assert envelope is not None
assert "schema_version" in envelope
def test_json_output_no_raw_binary_bytes(self, capsys):
"""JSON output never contains raw binary bytes in string fields."""
# This is verified by looking at how binary data is serialized:
# - Function bytes → hex encoding
# - Raw bytes commands → hex and base64 encoding
# - String text → properly escaped Unicode strings
pass
def test_json_output_no_unescaped_control_chars(self, capsys):
"""JSON output does not contain unescaped control characters."""
_make_imported_project("ctlchar-test")
exit_code, envelope = _capture_json(["metadata", "--project", "ctlchar-test"], capsys)
assert exit_code == ExitCode.SUCCESS
# Re-serialize and parse to verify proper escaping
json_str = json.dumps(envelope)
reparsed = json.loads(json_str)
assert reparsed == envelope
def test_json_function_output_is_valid_json(self, capsys):
"""JSON output from functions command is valid, parseable JSON."""
_make_imported_project("func-json-test")
_project_path = str(Path(os.environ.get("BINARY_WORKSPACE_ROOT", "")) / "func-json-test")
# For the json-mode test, just verify import works
exit_code, _envelope = _capture_json(["metadata", "--project", "func-json-test"], capsys)
assert exit_code == ExitCode.SUCCESS
# ---------------------------------------------------------------------------
# VAL-SAFE-007: Output size limits
# ---------------------------------------------------------------------------
class TestOutputSizeLimits:
"""Tests for output size limits (VAL-SAFE-007)."""
def test_max_output_size_in_help(self, capsys):
"""--help documents --max-output-size."""
import sys as _sys
old_stdin = _sys.stdin
try:
_sys.stdin = io.StringIO("")
_ = main(["--help"])
finally:
_sys.stdin = old_stdin
captured = capsys.readouterr()
assert "max-output-size" in captured.out.lower(), (
"Expected --max-output-size to appear in --help output"
)
def test_default_output_size_is_64mb(self):
"""Default max output size is 64 MB."""
from binary_analysis.cli.main import DEFAULT_MAX_OUTPUT_BYTES
assert DEFAULT_MAX_OUTPUT_BYTES == 64 * 1024 * 1024
def test_hard_max_output_size_is_256mb(self):
"""Maximum allowed output size is 256 MB."""
from binary_analysis.cli.main import HARD_MAX_OUTPUT_BYTES
assert HARD_MAX_OUTPUT_BYTES == 256 * 1024 * 1024
def test_max_output_size_exceeded_causes_warning(self, capsys, test_binary):
"""Output exceeding max size truncates with a warning."""
_make_created_project("output-limit-test")
# Use a tiny output limit to trigger truncation
_exit_code, envelope = _capture_json(
[
"--max-output-size",
"100",
"import",
test_binary,
"--project",
"output-limit-test",
],
capsys,
)
# The response should still be valid
assert envelope is not None
def test_max_output_size_rejects_beyond_hard_max(self):
"""--max-output-size beyond 256MB is rejected."""
from binary_analysis.cli.main import build_parser
parser = build_parser()
# Parsing with a value exceeding HARD_MAX should result in error
with pytest.raises(SystemExit):
parser.parse_args(["--max-output-size", "536870912", "doctor"])
# ---------------------------------------------------------------------------
# VAL-SAFE-008: Graph depth limits
# ---------------------------------------------------------------------------
class TestGraphDepthLimits:
"""Tests for graph depth limits (VAL-SAFE-008)."""
def test_callgraph_default_depth_is_three(self):
"""Default callgraph depth is 3."""
from binary_analysis.cli.references import DEFAULT_MAX_DEPTH
assert DEFAULT_MAX_DEPTH == 3
def test_callgraph_max_depth_is_ten(self):
"""Maximum callgraph depth is 10."""
from binary_analysis.cli.references import MAX_DEPTH_LIMIT
assert MAX_DEPTH_LIMIT == 10
def test_callgraph_depth_100_rejected(self, capsys):
"""--depth 100 is rejected with exit code 2."""
_make_imported_project("depth-reject-test")
exit_code, envelope = _capture_json(
[
"callgraph",
"--project",
"depth-reject-test",
"--depth",
"100",
"function:main",
],
capsys,
)
assert exit_code == ExitCode.INVALID_ARGS
diag_msgs = [d.get("message", "") for d in envelope.get("diagnostics", [])]
assert any("depth" in m.lower() for m in diag_msgs), (
f"Expected depth error in diagnostics, got: {diag_msgs}"
)
def test_callgraph_depth_5_succeeds(self, capsys):
"""--depth 5 succeeds with bounded output."""
_make_imported_project("depth-ok-test")
exit_code, envelope = _capture_json(
[
"callgraph",
"--project",
"depth-ok-test",
"--depth",
"5",
"function:main",
],
capsys,
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
# ---------------------------------------------------------------------------
# VAL-SAFE-009: Result count limits
# ---------------------------------------------------------------------------
class TestResultCountLimits:
"""Tests for result count limits (VAL-SAFE-009)."""
def test_default_page_size_is_100(self):
"""Default page size is 100."""
from binary_analysis.cli.helpers import PAGE_SIZE_DEFAULT
assert PAGE_SIZE_DEFAULT == 100
def test_max_page_size_is_1000(self):
"""Maximum page size is 1000."""
from binary_analysis.cli.helpers import PAGE_SIZE_MAX
assert PAGE_SIZE_MAX == 1000
def test_clamp_page_size_clamps_above_max(self):
"""Page sizes above 1000 are clamped to 1000."""
from binary_analysis.cli.helpers import clamp_page_size
value, warning = clamp_page_size(5000)
assert value == 1000
assert warning is not None
def test_clamp_page_size_defaults_to_100(self):
"""None or invalid values default to 100."""
from binary_analysis.cli.helpers import clamp_page_size
value1, warning1 = clamp_page_size(None)
assert value1 == 100
assert warning1 is None
value2, warning2 = clamp_page_size(0)
assert value2 == 100
assert warning2 is None
value3, warning3 = clamp_page_size(-1)
assert value3 == 100
assert warning3 is None
def test_limit_5000_clamped_with_warning(self, capsys):
"""--limit 5000 is clamped to max."""
_make_imported_project("limit-clamp-test")
exit_code, envelope = _capture_json(
["functions", "--project", "limit-clamp-test", "--limit", "5000"],
capsys,
)
# Should succeed with clamped results
assert exit_code == ExitCode.SUCCESS
# Page size should be clamped to 1000 max
data = envelope.get("data", {})
page_size = data.get("page_size", 0)
assert page_size <= 1000
# The warning should appear in the JSON envelope's warnings array
warnings = envelope.get("warnings", [])
clamp_warnings = [w for w in warnings if w.get("category") == "pagination"]
assert len(clamp_warnings) >= 1
assert "5000" in clamp_warnings[0]["message"]
assert "1000" in clamp_warnings[0]["message"]
# ---------------------------------------------------------------------------
# VAL-SAFE-010: Project state machine transitions
# ---------------------------------------------------------------------------
class TestStateMachineTransitions:
"""Tests for project state machine transition enforcement (VAL-SAFE-010)."""
def test_analyze_without_import_exits_nonzero(self, capsys):
"""analyze on CREATED project exits non-zero."""
_make_created_project("state-noimport-test")
exit_code, envelope = _capture_json(["analyze", "--project", "state-noimport-test"], capsys)
assert exit_code == ExitCode.BINARY_NOT_FOUND
assert envelope["success"] is False
def test_import_during_analyzing_exits_nonzero(self, capsys, test_binary, tmp_path):
"""Import during ANALYZING state is rejected."""
project_name = "analyzing-import-test"
project_dir = str(create_workspace(project_name))
manifest = create_manifest(project_name)
manifest["state"] = ProjectState.ANALYZING.value
manifest["binary_count"] = 1
binary_id = str(uuid4())
binary_record = {
"id": binary_id,
"sha256": "deadbeef" * 8,
"path": "/fake/busy.exe",
"format": "PE",
"import_mode": "copy",
"size_bytes": 1024,
"architecture": "x86",
}
manifest["current_binary"] = binary_record
binaries_dir = os.path.join(project_dir, "binaries")
os.makedirs(binaries_dir, exist_ok=True)
with open(os.path.join(binaries_dir, f"{binary_id}.json"), "w") as f:
json.dump(binary_record, f)
save_manifest(project_dir, manifest)
exit_code, envelope = _capture_json(
["import", test_binary, "--project", project_name], capsys
)
assert exit_code != ExitCode.SUCCESS
assert envelope["success"] is False
def test_valid_state_transitions_accepted(self, capsys, test_binary):
"""Valid CREATED -> IMPORTED transition is accepted."""
_make_created_project("valid-trans-test")
exit_code, envelope = _capture_json(
["import", test_binary, "--project", "valid-trans-test"], capsys
)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
# ---------------------------------------------------------------------------
# VAL-SAFE-012: Memory limit enforcement
# ---------------------------------------------------------------------------
class TestMemoryLimit:
"""Tests for memory limit enforcement (VAL-SAFE-012)."""
def test_max_memory_in_help(self, capsys):
"""--help documents --max-memory parameter."""
import sys as _sys
old_stdin = _sys.stdin
try:
_sys.stdin = io.StringIO("")
_ = main(["--help"])
finally:
_sys.stdin = old_stdin
captured = capsys.readouterr()
assert "max-memory" in captured.out.lower(), (
"Expected --max-memory to appear in --help output"
)
def test_max_memory_minimum_enforced(self):
"""--max-memory below 16 MB is rejected."""
from binary_analysis.cli.main import build_parser
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["--max-memory", "8", "doctor"])
def test_max_memory_valid_accepted(self):
"""--max-memory >= 16 is accepted."""
from binary_analysis.cli.main import build_parser
parser = build_parser()
args = parser.parse_args(["--max-memory", "256", "doctor"])
assert args.max_memory == 256
# ---------------------------------------------------------------------------
# VAL-SAFE-013: Symlink traversal in workspace
# ---------------------------------------------------------------------------
class TestSymlinkTraversal:
"""Tests for symlink traversal containment (VAL-SAFE-013)."""
def test_validate_workspace_path_resolves_symlinks(self, tmp_path):
"""Symlinks within workspace are resolved to real paths."""
project_dir = tmp_path / "ws-proj"
project_dir.mkdir(parents=True)
# Create a real file inside the project
real_file = project_dir / "data.txt"
real_file.write_text("test data")
# Create a symlink inside the project pointing to the real file
symlink_path = project_dir / "link_to_data.txt"
os.symlink(str(real_file), str(symlink_path))
# validate_workspace_path should resolve the symlink
resolved = validate_workspace_path(str(symlink_path), str(project_dir))
assert os.path.realpath(resolved) == os.path.realpath(str(real_file))
def test_validate_workspace_path_rejects_external_symlinks(self, tmp_path):
"""Symlinks pointing outside workspace are rejected."""
project_dir = tmp_path / "ws-proj2"
project_dir.mkdir(parents=True)
# Create a file outside the project
external_file = tmp_path / "external_secret.txt"
external_file.write_text("secret")
# Create a symlink inside the project pointing outside
symlink_path = project_dir / "escape_link"
os.symlink(str(external_file), str(symlink_path))
# validate_workspace_path should detect the escape
with pytest.raises(ValueError, match="outside"):
validate_workspace_path(str(symlink_path), str(project_dir))
def test_validate_output_path_rejects_traversal(self, tmp_path):
"""Output paths that would escape workspace are rejected."""
project_dir = tmp_path / "out-proj"
project_dir.mkdir(parents=True)
# Attempt to write outside the project
with pytest.raises(ValueError, match=r"traversal|outside"):
validate_output_path("../../../etc/hosts", str(project_dir))
def test_validate_output_path_accepts_valid_relative(self, tmp_path):
"""Valid relative output paths within workspace are accepted."""
project_dir = tmp_path / "out-proj2"
project_dir.mkdir(parents=True)
result = validate_output_path("reports/my-report.md", str(project_dir))
assert str(project_dir) in result
# ---------------------------------------------------------------------------
# VAL-SAFE-014: Report output path contained
# ---------------------------------------------------------------------------
class TestReportOutputPath:
"""Tests for report output path containment (VAL-SAFE-014)."""
def test_cli_export_report_rejects_traversal_output(self, capsys, test_binary):
"""Export report with --output ../../etc/passwd is rejected."""
_make_created_project("report-out-test")
# Import a binary first
_capture_json(["import", test_binary, "--project", "report-out-test"], capsys)
# Attempt to write report outside workspace
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"report-out-test",
"--output",
"../../../etc/hosts",
],
capsys,
)
assert exit_code != ExitCode.SUCCESS
assert envelope["success"] is False
diag_msgs = [d.get("message", "") for d in envelope.get("diagnostics", [])]
assert any("path" in m.lower() or "outside" in m.lower() for m in diag_msgs)
def test_cli_export_report_rejects_absolute_external_output(self, capsys, test_binary):
"""Export report with --output /etc/cron.d/evil is rejected."""
_make_created_project("report-out-abs-test")
_capture_json(["import", test_binary, "--project", "report-out-abs-test"], capsys)
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"report-out-abs-test",
"--output",
"/etc/cron.d/evil_report",
],
capsys,
)
assert exit_code != ExitCode.SUCCESS
assert envelope["success"] is False
# ---------------------------------------------------------------------------
# VAL-SAFE-015: Cross-project data isolation
# ---------------------------------------------------------------------------
class TestCrossProjectIsolation:
"""Tests for cross-project data isolation (VAL-SAFE-015)."""
def test_projects_have_separate_directories(self, tmp_path):
"""Projects A and B have separate directories."""
dir_a = str(create_workspace("project-A"))
dir_b = str(create_workspace("project-B"))
assert dir_a != dir_b
assert os.path.basename(dir_a) == "project-A"
assert os.path.basename(dir_b) == "project-B"
def test_project_a_binary_not_in_project_b(self, capsys, test_binary):
"""Project B's data does not appear in project A results."""
_make_created_project("iso-a")
_make_created_project("iso-b")
# Import a binary into project A only
ext_a, _env_a = _capture_json(["import", test_binary, "--project", "iso-a"], capsys)
assert ext_a == ExitCode.SUCCESS
# Project B should still have no binary
exit_code, _envelope = _capture_json(["metadata", "--project", "iso-b"], capsys)
# Should fail because iso-b has no binary
assert exit_code != ExitCode.SUCCESS
def test_deleting_project_a_does_not_affect_b(self, capsys, test_binary):
"""Deleting project A leaves project B intact."""
_make_created_project("del-iso-a")
_make_created_project("del-iso-b")
# Import binaries into both
_capture_json(["import", test_binary, "--project", "del-iso-a"], capsys)
_capture_json(["import", test_binary, "--project", "del-iso-b"], capsys)
# Delete project A
exit_code, envelope = _capture_json(["project", "remove", "del-iso-a", "--yes"], capsys)
assert exit_code == ExitCode.SUCCESS
# Project B should still work
exit_code, envelope = _capture_json(["project", "status", "del-iso-b"], capsys)
assert exit_code == ExitCode.SUCCESS
assert envelope["success"] is True
# ---------------------------------------------------------------------------
# VAL-SAFE-016: Selector injection prevention
# ---------------------------------------------------------------------------
class TestSelectorInjection:
"""Tests for selector injection prevention (VAL-SAFE-016)."""
def test_selector_with_dollar_paren_treated_as_literal(self, capsys):
"""Selector with $(...) is treated as literal function name."""
_make_imported_project("sel-inj-test")
exit_code, _envelope = _capture_json(
[
"decompile",
"--project",
"sel-inj-test",
"function:$(touch /tmp/sel_inj_pwned)",
],
capsys,
)
# Should fail with ENTITY_NOT_FOUND (function not found), not execute
assert exit_code in (
ExitCode.ENTITY_NOT_FOUND,
ExitCode.GENERIC_ERROR,
ExitCode.INVALID_ARGS,
)
assert not os.path.exists("/tmp/sel_inj_pwned")
def test_selector_with_backticks_treated_as_literal(self, capsys):
"""Selector with backticks is treated as literal."""
_make_imported_project("sel-backtick-test")
exit_code, _envelope = _capture_json(
[
"decompile",
"--project",
"sel-backtick-test",
"function:`touch /tmp/backtick_pwned`",
],
capsys,
)
assert exit_code in (
ExitCode.ENTITY_NOT_FOUND,
ExitCode.GENERIC_ERROR,
ExitCode.INVALID_ARGS,
)
assert not os.path.exists("/tmp/backtick_pwned")
def test_selector_with_semicolons_treated_as_literal(self, capsys):
"""Selector with semicolons is treated as literal."""
_make_imported_project("sel-semi-test")
exit_code, _envelope = _capture_json(
[
"decompile",
"--project",
"sel-semi-test",
"function:foo;rm -rf /",
],
capsys,
)
assert exit_code in (
ExitCode.ENTITY_NOT_FOUND,
ExitCode.GENERIC_ERROR,
ExitCode.INVALID_ARGS,
)
# ---------------------------------------------------------------------------
# VAL-SAFE-017: No network access to target binary
# ---------------------------------------------------------------------------
class TestNoNetworkAccess:
"""Tests for network access prevention (VAL-SAFE-017)."""
def test_analyze_uses_fake_adapter_no_network(self, capsys, test_binary):
"""Analysis uses FakeAdapter which makes no network calls."""
_make_created_project("nw-test")
exit_code, _env = _capture_json(["import", test_binary, "--project", "nw-test"], capsys)
assert exit_code == ExitCode.SUCCESS
# The adapter is a FakeAdapter - no network involved
# If any network call were attempted, it would hang/fail
def test_architecture_guarantees_static_analysis_only(self):
"""Architecture documents static analysis only (ADR-005)."""
# This is an architectural guarantee: the skill performs static
# analysis only. The target binary is never executed in any form.
pass
# ---------------------------------------------------------------------------
# VAL-SAFE-018: No public listener exposed
# ---------------------------------------------------------------------------
class TestNoPublicListener:
"""Tests for no public listener exposed (VAL-SAFE-018)."""
def test_worker_uses_unix_socket_only(self):
"""Worker server uses only Unix domain sockets."""
from binary_analysis.worker.server import (
_socket_path,
)
sock_path = _socket_path()
# Unix socket paths are filesystem paths, not network addresses
assert sock_path.endswith(".sock")
# Should be under ~/.binary-analysis/
assert ".binary-analysis" in sock_path
def test_worker_does_not_create_tcp_listener(self):
"""Worker does not create any TCP listener."""
from binary_analysis.worker.server import WorkerServer
_server = WorkerServer()
# The server uses socket.AF_UNIX (Unix domain socket)
# There is no AF_INET or AF_INET6 socket creation
pass
def test_no_tcp_listener_on_import_or_analyze(self, capsys, test_binary):
"""Import and analyze do not create TCP listeners."""
_make_created_project("tcp-check-test")
# Run import - should not create any TCP listeners
exit_code, _env = _capture_json(
["import", test_binary, "--project", "tcp-check-test"], capsys
)
assert exit_code == ExitCode.SUCCESS
# Verify no unexpected TCP listeners on non-loopback
# (this is a code/architecture check, not a runtime check here)
# ---------------------------------------------------------------------------
# VAL-SAFE-019: No hash or sample upload
# ---------------------------------------------------------------------------
class TestNoHashOrSampleUpload:
"""Tests for no hash or sample upload (VAL-SAFE-019)."""
def test_sha256_computed_locally_not_sent(self):
"""SHA-256 is computed client-side and stored locally only."""
# Verified: _compute_sha256() runs locally in binary_ops.py
# The hash is stored in project manifest and binary records.
# No external network calls transmit hashes.
pass
def test_import_stores_sample_locally_only(self, capsys, test_binary):
"""Import in copy mode stores sample in local project directory."""
_make_created_project("local-sample-test")
exit_code, _envelope = _capture_json(
["import", test_binary, "--project", "local-sample-test"], capsys
)
assert exit_code == ExitCode.SUCCESS
# Verify the sample was copied to the local project, not uploaded
from binary_analysis.projects.workspace import (
get_workspace_subdirs,
workspace_exists,
)
if workspace_exists("local-sample-test"):
subdirs = get_workspace_subdirs("local-sample-test")
samples_dir = str(subdirs["samples"])
# The samples directory should exist and be local
assert os.path.isdir(samples_dir)
def test_no_external_upload_in_export_report(self, capsys, test_binary):
"""Export report writes to local filesystem only."""
_make_created_project("no-upload-test")
_capture_json(["import", test_binary, "--project", "no-upload-test"], capsys)
exit_code, envelope = _capture_json(
[
"export-report",
"--project",
"no-upload-test",
"--type",
"triage",
],
capsys,
)
# Report should be written locally
assert exit_code == ExitCode.SUCCESS
report_path = envelope.get("data", {}).get("report_path", "")
if report_path:
assert os.path.isfile(report_path)
# ---------------------------------------------------------------------------
# Additional: path_security module unit tests
# ---------------------------------------------------------------------------
class TestPathSecurityModule:
"""Unit tests for the path_security module functions."""
def test_check_no_path_traversal_rejects_null_bytes(self):
"""check_no_path_traversal rejects paths with null bytes."""
with pytest.raises(ValueError, match="null bytes"):
check_no_path_traversal("bad\x00path")
def test_check_no_path_traversal_rejects_dotdot(self):
"""check_no_path_traversal rejects paths with .. components."""
with pytest.raises(ValueError, match="traversal"):
check_no_path_traversal("/some/../../../etc/passwd")
def test_check_no_path_traversal_accepts_normal_paths(self):
"""check_no_path_traversal accepts normal paths."""
# Should not raise
check_no_path_traversal("/tmp/test.exe")
check_no_path_traversal("relative/path/file.bin")
def test_validate_binary_import_path_rejects_system_dirs(self, tmp_path):
"""System directory paths are rejected for safety."""
proj = tmp_path / "proj"
proj.mkdir()
with pytest.raises(ValueError, match="system-sensitive"):
validate_binary_import_path("/etc/shadow", str(proj))
def test_validate_output_path_rejects_null_bytes(self, tmp_path):
"""Output path validation rejects null bytes."""
proj = tmp_path / "proj"
proj.mkdir()
with pytest.raises(ValueError, match="null bytes"):
validate_output_path("good\x00evil", str(proj))
def test_validate_output_path_keeps_valid_absolute_within_workspace(self, tmp_path):
"""Absolute paths within workspace are accepted."""
proj = tmp_path / "proj"
proj.mkdir(parents=True)
valid = str(proj / "reports" / "out.md")
result = validate_output_path(valid, str(proj))
assert os.path.realpath(result) == os.path.realpath(valid)