diff --git a/RangeCheck/src/rangecheck/scope.py b/RangeCheck/src/rangecheck/scope.py index cc6755a..cb88e88 100644 --- a/RangeCheck/src/rangecheck/scope.py +++ b/RangeCheck/src/rangecheck/scope.py @@ -1,6 +1,7 @@ from __future__ import annotations import ipaddress +import math from pathlib import Path from typing import Any @@ -27,7 +28,7 @@ def load_scope(path: Path) -> ScopeConfig: limits = _require_dict(raw, "limits") reporting = _require_dict(raw, "reporting") - authorized = bool(engagement.get("authorized", False)) + authorized = engagement.get("authorized") is True if not authorized: raise ScopeValidationError("Scope file must explicitly set engagement.authorized: true.") @@ -41,10 +42,10 @@ def load_scope(path: Path) -> ScopeConfig: for target in include_targets + exclude_targets: _validate_ip_or_cidr(target) - max_hosts = int(limits.get("max_hosts", 256)) - max_ports_per_host = int(limits.get("max_ports_per_host", 1000)) - timeout = float(limits.get("default_timeout_seconds", 1.5)) - concurrency = int(limits.get("default_concurrency", 100)) + max_hosts = _require_int(limits, "max_hosts", 256) + max_ports_per_host = _require_int(limits, "max_ports_per_host", 1000) + timeout = limits.get("default_timeout_seconds", 1.5) + concurrency = _require_int(limits, "default_concurrency", 100) if max_hosts < 1: raise ScopeValidationError("limits.max_hosts must be greater than 0.") @@ -52,8 +53,8 @@ def load_scope(path: Path) -> ScopeConfig: if max_ports_per_host < 1 or max_ports_per_host > 65535: raise ScopeValidationError("limits.max_ports_per_host must be between 1 and 65535.") - if timeout <= 0: - raise ScopeValidationError("limits.default_timeout_seconds must be greater than 0.") + if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0: + raise ScopeValidationError("limits.default_timeout_seconds must be a finite positive number.") if concurrency < 1: raise ScopeValidationError("limits.default_concurrency must be greater than 0.") @@ -82,6 +83,13 @@ def _validate_ip_or_cidr(value: str) -> None: raise ScopeValidationError(f"Invalid IP or CIDR target: {value}") from exc +def _require_int(raw: dict[str, Any], key: str, default: int) -> int: + value = raw.get(key, default) + if type(value) is not int: + raise ScopeValidationError(f"limits.{key} must be an integer, not a string or boolean.") + return value + + def _require_dict(raw: dict[str, Any], key: str) -> dict[str, Any]: value = raw.get(key) diff --git a/RangeCheck/tests/test_scope_types.py b/RangeCheck/tests/test_scope_types.py new file mode 100644 index 0000000..e0b8cc4 --- /dev/null +++ b/RangeCheck/tests/test_scope_types.py @@ -0,0 +1,52 @@ +"""Scope YAML must preserve explicit decisions and bounded numeric inputs.""" +import pytest +import yaml + +from rangecheck.scope import ScopeValidationError, load_scope + + +def fixture(): + return { + "engagement": {"name": "Local fixture", "owner": "Tester", "purpose": "Regression", + "authorized": True, "authorization_statement": "Loopback fixture only"}, + "targets": {"include": ["127.0.0.1"], "exclude": []}, + "limits": {}, "reporting": {}, + } + + +def load(tmp_path, raw): + path = tmp_path / "scope.yaml" + path.write_text(yaml.safe_dump(raw), encoding="utf-8") + return load_scope(path) + + +@pytest.mark.parametrize("value", ["false", "true", "yes", 1, 0, [], {}, None, False]) +def test_only_boolean_true_authorizes(tmp_path, value): + raw = fixture() + raw["engagement"]["authorized"] = value + with pytest.raises(ScopeValidationError, match="authorized"): + load(tmp_path, raw) + + +@pytest.mark.parametrize("key", ["max_hosts", "max_ports_per_host", "default_concurrency"]) +@pytest.mark.parametrize("value", [True, "10", 2.5, None]) +def test_integer_limits_do_not_coerce(tmp_path, key, value): + raw = fixture() + raw["limits"][key] = value + with pytest.raises(ScopeValidationError, match="integer"): + load(tmp_path, raw) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf"), True, "1.5", None, 0, -1]) +def test_timeout_is_finite_positive_number(tmp_path, value): + raw = fixture() + raw["limits"]["default_timeout_seconds"] = value + with pytest.raises(ScopeValidationError, match="finite positive"): + load(tmp_path, raw) + + +def test_defaults_preserve_a_valid_scope(tmp_path): + scope = load(tmp_path, fixture()) + assert scope.authorized is True + assert scope.default_timeout_seconds == 1.5 + assert scope.max_hosts == 256