Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/secuscan/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,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 candidate.is_absolute() or os.path.isabs(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",
Expand All @@ -370,8 +406,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]:
Expand Down
2 changes: 1 addition & 1 deletion testing/backend/integration/test_phase3_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
167 changes: 156 additions & 11 deletions testing/backend/unit/test_plugins.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import asyncio
import sys
from pathlib import Path

import pytest

from backend.secuscan.config import settings
from backend.secuscan.plugins import PluginManager

Expand Down Expand Up @@ -99,6 +102,152 @@ 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")


@pytest.mark.skipif(sys.platform != "win32", reason="Windows absolute path detection is OS-specific")
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())
Expand All @@ -115,17 +264,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"},
)
Loading