diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index 45dbdd19..e2574a79 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -33,6 +33,19 @@ logger = logging.getLogger(__name__) +def _is_absolute_path(value: str) -> bool: + """Check if a path is absolute regardless of the server OS. + + Handles Unix (/), Windows drive-letter (C:\\, C:/), + and UNC (\\\\server\\share) absolute path styles. + """ + if value.startswith("/"): + return True + if value.startswith("\\"): + return True + return bool(re.match(r'^[a-zA-Z]:[/\\]', value)) + + class PluginManager: """Manages plugin loading and validation""" @@ -337,13 +350,49 @@ def _with_field_defaults(self, plugin: PluginMetadata, inputs: Dict[str, Any]) - normalized[field.id] = field.default return normalized + def _reject_path_traversal(self, value: str) -> None: + """Raise ValueError if value contains parent-directory traversal components.""" + normalized = value.replace("\\", os.sep).replace("/", os.sep) + parts = normalized.split(os.sep) + if ".." in parts: + raise ValueError( + f"Wordlist path {value!r} contains parent-directory traversal ('..'), " + f"which is not allowed." + ) + + def _is_path_in_wordlists_dir(self, resolved: Path) -> bool: + """Check that a resolved path is within the configured wordlists directory.""" + wordlists_dir = Path(settings.wordlists_dir).resolve() + try: + resolved.resolve().relative_to(wordlists_dir) + return True + except ValueError: + return False + def _resolve_wordlist_path(self, value: str) -> str: """Resolve plugin wordlist aliases and Linux-centric defaults to local project assets.""" candidate = Path(os.path.expanduser(value)) + + if _is_absolute_path(value): + raise ValueError( + f"Wordlist path must be relative, got absolute path: {value!r}" + ) + + self._reject_path_traversal(value) + if candidate.exists(): + resolved = candidate.resolve() + if not self._is_path_in_wordlists_dir(resolved): + raise ValueError( + f"Wordlist path {value!r} resolves outside the allowed wordlists directory " + f"({settings.wordlists_dir}). Only paths within the wordlists directory " + f"are permitted by default." + ) return str(candidate) wordlists_dir = Path(settings.wordlists_dir) + wordlists_resolved = wordlists_dir.resolve() + alias_map = { "small": wordlists_dir / "small.txt", "medium": wordlists_dir / "medium.txt", @@ -370,8 +419,18 @@ def _resolve_wordlist_path(self, value: str) -> str: for fallback in fallback_candidates: if fallback.exists(): + resolved = fallback.resolve() + if wordlists_resolved not in resolved.parents and resolved != wordlists_resolved: + continue return str(fallback) + # Before returning the raw value, verify it doesn't escape + resolved_value = (wordlists_dir / value).resolve() + if wordlists_resolved not in resolved_value.parents and resolved_value != wordlists_resolved: + raise ValueError( + f"Wordlist path {value!r} escapes the wordlists directory" + ) + return value def _normalize_inputs(self, plugin: PluginMetadata, inputs: Dict[str, Any]) -> Dict[str, Any]: diff --git a/testing/backend/integration/test_phase3_plugins.py b/testing/backend/integration/test_phase3_plugins.py index 637edb2e..1f0461a1 100644 --- a/testing/backend/integration/test_phase3_plugins.py +++ b/testing/backend/integration/test_phase3_plugins.py @@ -129,7 +129,7 @@ def test_hashcat(test_client): result = run_plugin_test( test_client, "hashcat", - {"target": "/tmp/hashes.txt", "hash_type": 0, "attack_mode": 0, "wordlist": "/tmp/words.txt"}, + {"target": "/tmp/hashes.txt", "hash_type": 0, "attack_mode": 0, "wordlist": "words.txt"}, mock_out, ) assert any("Hash Recovered" in f["title"] for f in result["structured"]["findings"]) diff --git a/testing/backend/unit/test_plugins.py b/testing/backend/unit/test_plugins.py index eb693b0a..45e5e6ac 100644 --- a/testing/backend/unit/test_plugins.py +++ b/testing/backend/unit/test_plugins.py @@ -1,6 +1,8 @@ import asyncio from pathlib import Path +import pytest + from backend.secuscan.config import settings from backend.secuscan.plugins import PluginManager @@ -99,6 +101,151 @@ def test_nikto_plugin_supports_expanded_cli_parameters(setup_test_environment): assert "-nocache" in command +# --------------------------------------------------------------------------- +# _resolve_wordlist_path unit tests — isolated path safety & resolution +# --------------------------------------------------------------------------- + + +def test_resolve_wordlist_path_rejects_absolute_unix_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="absolute"): + manager._resolve_wordlist_path("/etc/passwd") + + +def test_resolve_wordlist_path_rejects_absolute_windows_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="absolute"): + manager._resolve_wordlist_path("C:\\Windows\\system32") + + +def test_resolve_wordlist_path_rejects_traversal(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("../../../etc/passwd") + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("..\\..\\..\\etc\\passwd") + + +def test_resolve_wordlist_path_blocks_escaped_existing_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("..\\outside.txt") + + +def test_resolve_wordlist_path_alias_small_works(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + small = wordlists_dir / "small.txt" + small.write_text("a\nb\nc") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("small") + assert result == str(small) + + +def test_resolve_wordlist_path_alias_medium_works(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + medium = wordlists_dir / "medium.txt" + medium.write_text("a\nb\nc") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("medium") + assert result == str(medium) + + +def test_resolve_wordlist_path_alias_large_works(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + large = wordlists_dir / "large.txt" + large.write_text("a\nb\nc") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("large") + assert result == str(large) + + +def test_resolve_wordlist_path_fallback_dirb_common(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + common = wordlists_dir / "common.txt" + common.write_text("common") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("dirb/common.txt") + assert result == str(common) + + +def test_resolve_wordlist_path_fallback_seclists_common(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + common = wordlists_dir / "common.txt" + common.write_text("common") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("discovery/web-content/common.txt") + assert result == str(common) + + +def test_resolve_wordlist_path_fallback_seclists_dns(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + subdomains = wordlists_dir / "subdomains-top1million-110000.txt" + subdomains.write_text("www\napi") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("discovery/dns/subdomains-top1million-110000.txt") + assert result == str(subdomains) + + +def test_resolve_wordlist_path_returns_value_unchanged_when_not_found(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + result = manager._resolve_wordlist_path("custom_wordlist.txt") + assert result == "custom_wordlist.txt" + + +def test_resolve_wordlist_path_blocks_escaped_nonexistent_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("../plugins/malicious_script") + + +# --------------------------------------------------------------------------- +# Existing wordlist integration-style tests (use real files on disk) +# --------------------------------------------------------------------------- + + def test_plugin_manager_resolves_repo_local_wordlist_aliases(setup_test_environment): manager = PluginManager(settings.plugins_dir) asyncio.run(manager.load_plugins()) @@ -115,17 +262,13 @@ def test_plugin_manager_resolves_repo_local_wordlist_aliases(setup_test_environm assert str(medium_wordlist) in command -def test_plugin_manager_resolves_linux_wordlist_defaults_to_repo_assets(setup_test_environment): +def test_plugin_manager_rejects_linux_wordlist_absolute_default(setup_test_environment): + """Linux absolute paths in plugin defaults are now rejected for safety.""" manager = PluginManager(settings.plugins_dir) asyncio.run(manager.load_plugins()) - fallback_wordlist = Path(settings.wordlists_dir) / "subdomains-top1million-110000.txt" - fallback_wordlist.write_text("www\napi\n", encoding="utf-8") - - command = manager.build_command( - "virtual-host-finder", - {"target": "example.com"}, - ) - - assert command is not None - assert str(fallback_wordlist) in command + with pytest.raises(ValueError, match="absolute"): + manager.build_command( + "virtual-host-finder", + {"target": "example.com"}, + )