Spaces:
Runtime error
Runtime error
Merge pull request #92 from open-cybernauts/fix/issue-58-validator-shell-sanitization
Browse files
src/open_range/validator/isolation.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
| 5 |
from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
|
| 6 |
|
| 7 |
# Common service ports to probe for zone isolation violations.
|
|
@@ -33,10 +35,14 @@ class IsolationCheck:
|
|
| 33 |
open_ports: list[int] = []
|
| 34 |
for port in _PROBE_PORTS:
|
| 35 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
output = await containers.exec(
|
| 37 |
attacker_host,
|
| 38 |
-
|
| 39 |
-
f"2>/dev/null && echo OPEN || echo CLOSED",
|
| 40 |
)
|
| 41 |
if "OPEN" in output:
|
| 42 |
open_ports.append(port)
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import shlex
|
| 6 |
+
|
| 7 |
from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
|
| 8 |
|
| 9 |
# Common service ports to probe for zone isolation violations.
|
|
|
|
| 35 |
open_ports: list[int] = []
|
| 36 |
for port in _PROBE_PORTS:
|
| 37 |
try:
|
| 38 |
+
probe_cmd = (
|
| 39 |
+
"timeout 2 bash -lc 'echo > /dev/tcp/\"$1\"/\"$2\"' _ "
|
| 40 |
+
f"{shlex.quote(target_name)} {shlex.quote(str(port))} "
|
| 41 |
+
"2>/dev/null && echo OPEN || echo CLOSED"
|
| 42 |
+
)
|
| 43 |
output = await containers.exec(
|
| 44 |
attacker_host,
|
| 45 |
+
probe_cmd,
|
|
|
|
| 46 |
)
|
| 47 |
if "OPEN" in output:
|
| 48 |
open_ports.append(port)
|
tests/test_validator.py
CHANGED
|
@@ -8,6 +8,7 @@ import pytest
|
|
| 8 |
from open_range.protocols import (
|
| 9 |
CheckResult,
|
| 10 |
EvidenceItem,
|
|
|
|
| 11 |
ExploitStep,
|
| 12 |
FlagSpec,
|
| 13 |
GoldenPathStep,
|
|
@@ -795,6 +796,36 @@ async def test_evidence_fails_on_nonzero_exit_even_when_output_present(mock_cont
|
|
| 795 |
assert result.details["missing"][0]["location"] == "siem:/var/log/test.log"
|
| 796 |
|
| 797 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 798 |
# ---------------------------------------------------------------------------
|
| 799 |
# Check 5: Reward grounding
|
| 800 |
# ---------------------------------------------------------------------------
|
|
@@ -1053,7 +1084,7 @@ async def test_isolation_fails_on_non_ssh_port(mock_containers):
|
|
| 1053 |
# Only port 3306 is OPEN; everything else CLOSED.
|
| 1054 |
async def exec_side_effect(container, cmd, **kwargs):
|
| 1055 |
if container == "attacker" and "/dev/tcp/" in cmd:
|
| 1056 |
-
if "
|
| 1057 |
return "OPEN"
|
| 1058 |
return "CLOSED"
|
| 1059 |
return ""
|
|
@@ -1065,6 +1096,38 @@ async def test_isolation_fails_on_non_ssh_port(mock_containers):
|
|
| 1065 |
assert "db" in result.error
|
| 1066 |
|
| 1067 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1068 |
# ---------------------------------------------------------------------------
|
| 1069 |
# Check 7: Task feasibility
|
| 1070 |
# ---------------------------------------------------------------------------
|
|
|
|
| 8 |
from open_range.protocols import (
|
| 9 |
CheckResult,
|
| 10 |
EvidenceItem,
|
| 11 |
+
ExecResult,
|
| 12 |
ExploitStep,
|
| 13 |
FlagSpec,
|
| 14 |
GoldenPathStep,
|
|
|
|
| 796 |
assert result.details["missing"][0]["location"] == "siem:/var/log/test.log"
|
| 797 |
|
| 798 |
|
| 799 |
+
@pytest.mark.asyncio
|
| 800 |
+
async def test_evidence_quotes_pattern_and_location_path():
|
| 801 |
+
"""Evidence grep command must quote pattern and path from snapshot content."""
|
| 802 |
+
import shlex
|
| 803 |
+
|
| 804 |
+
from open_range.validator.evidence import EvidenceCheck
|
| 805 |
+
|
| 806 |
+
class RecordingContainers:
|
| 807 |
+
def __init__(self) -> None:
|
| 808 |
+
self.calls: list[tuple[str, str]] = []
|
| 809 |
+
|
| 810 |
+
async def exec_run(self, container: str, cmd: str, **kwargs) -> ExecResult:
|
| 811 |
+
self.calls.append((container, cmd))
|
| 812 |
+
return ExecResult(stdout="1", exit_code=0)
|
| 813 |
+
|
| 814 |
+
containers = RecordingContainers()
|
| 815 |
+
pattern = "ERR'; touch /tmp/pwn #"
|
| 816 |
+
path = "/var/log/app; echo PWNED"
|
| 817 |
+
spec = SnapshotSpec(
|
| 818 |
+
evidence_spec=[
|
| 819 |
+
EvidenceItem(type="log_entry", location=f"siem:{path}", pattern=pattern),
|
| 820 |
+
],
|
| 821 |
+
)
|
| 822 |
+
|
| 823 |
+
result = await EvidenceCheck().check(spec, containers) # type: ignore[arg-type]
|
| 824 |
+
assert result.passed is True
|
| 825 |
+
assert containers.calls
|
| 826 |
+
assert containers.calls[0][1] == f"grep -c {shlex.quote(pattern)} {shlex.quote(path)}"
|
| 827 |
+
|
| 828 |
+
|
| 829 |
# ---------------------------------------------------------------------------
|
| 830 |
# Check 5: Reward grounding
|
| 831 |
# ---------------------------------------------------------------------------
|
|
|
|
| 1084 |
# Only port 3306 is OPEN; everything else CLOSED.
|
| 1085 |
async def exec_side_effect(container, cmd, **kwargs):
|
| 1086 |
if container == "attacker" and "/dev/tcp/" in cmd:
|
| 1087 |
+
if " 3306 " in cmd:
|
| 1088 |
return "OPEN"
|
| 1089 |
return "CLOSED"
|
| 1090 |
return ""
|
|
|
|
| 1096 |
assert "db" in result.error
|
| 1097 |
|
| 1098 |
|
| 1099 |
+
@pytest.mark.asyncio
|
| 1100 |
+
async def test_isolation_uses_argument_safe_tcp_probe_for_target_name():
|
| 1101 |
+
"""Target names are passed as positional args, not interpolated into script."""
|
| 1102 |
+
from open_range.validator.isolation import IsolationCheck
|
| 1103 |
+
|
| 1104 |
+
class RecordingContainers:
|
| 1105 |
+
def __init__(self) -> None:
|
| 1106 |
+
self.calls: list[tuple[str, str]] = []
|
| 1107 |
+
|
| 1108 |
+
async def exec(self, container: str, cmd: str, **kwargs) -> str:
|
| 1109 |
+
self.calls.append((container, cmd))
|
| 1110 |
+
return "CLOSED"
|
| 1111 |
+
|
| 1112 |
+
containers = RecordingContainers()
|
| 1113 |
+
target = "db'; touch /tmp/pwn #"
|
| 1114 |
+
spec = SnapshotSpec(
|
| 1115 |
+
topology={"hosts": ["attacker", "db"], "zones": {"internal": [target]}},
|
| 1116 |
+
flags=[],
|
| 1117 |
+
golden_path=[],
|
| 1118 |
+
task=TaskSpec(red_briefing="Go.", blue_briefing="Watch."),
|
| 1119 |
+
)
|
| 1120 |
+
|
| 1121 |
+
result = await IsolationCheck().check(spec, containers) # type: ignore[arg-type]
|
| 1122 |
+
assert result.passed is True
|
| 1123 |
+
assert containers.calls
|
| 1124 |
+
first_cmd = containers.calls[0][1]
|
| 1125 |
+
script_part, _, arg_part = first_cmd.partition(" _ ")
|
| 1126 |
+
assert "bash -lc 'echo > /dev/tcp/\"$1\"/\"$2\"'" in script_part
|
| 1127 |
+
assert "touch /tmp/pwn" not in script_part
|
| 1128 |
+
assert "touch /tmp/pwn" in arg_part
|
| 1129 |
+
|
| 1130 |
+
|
| 1131 |
# ---------------------------------------------------------------------------
|
| 1132 |
# Check 7: Task feasibility
|
| 1133 |
# ---------------------------------------------------------------------------
|