mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-16 14:06:27 +03:00
1072 lines
41 KiB
Python
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)
|