diff --git a/.docker/Dockerfile.alpine b/.docker/Dockerfile.alpine index 41d55d445..3b60c26b6 100644 --- a/.docker/Dockerfile.alpine +++ b/.docker/Dockerfile.alpine @@ -41,10 +41,18 @@ RUN apk add --no-cache \ RUN adduser -D secator -# Allow runing 'nmap' with sudo; for priviledged scans -RUN echo "secator ALL=(ALL) NOPASSWD: /usr/bin/nmap *" >> /etc/sudoers && \ - echo "secator ALL=(ALL) NOPASSWD: /usr/bin/arp-scan *" >> /etc/sudoers && \ - echo "secator ALL=(ALL) NOPASSWD: /sbin/arp *" >> /etc/sudoers +# Raw-socket tools (nmap/arp-scan/arp) need CAP_NET_RAW/CAP_NET_ADMIN. +# full flavor: no baked-in sudoers grant here -- tools are installed below, then +# switched to file capabilities (see the setcap block after tool install). A +# `NOPASSWD: /usr/bin/nmap *` sudoers entry is a GTFObins root shell (a shell +# injection in a worker can `sudo nmap --script=/tmp/x.nse ...` -> root). +# lite flavor: tools are installed at runtime, so there are no baked binaries to +# setcap yet -- keep the old sudo grants here. Prefer the full image in prod. +RUN if [ "$flavor" = "lite" ]; then \ + echo "secator ALL=(ALL) NOPASSWD: /usr/bin/nmap *" >> /etc/sudoers && \ + echo "secator ALL=(ALL) NOPASSWD: /usr/bin/arp-scan *" >> /etc/sudoers && \ + echo "secator ALL=(ALL) NOPASSWD: /sbin/arp *" >> /etc/sudoers; \ + fi # Allow user to install commands RUN echo "secator ALL=(ALL) NOPASSWD: /sbin/apk add *" >> /etc/sudoers && \ @@ -74,6 +82,19 @@ RUN if [ "$flavor" != "lite" ]; then \ sed -i '/secator ALL=(ALL) NOPASSWD: \/usr\/bin\/flock \/tmp\/install.lock apk add \*/d' /etc/sudoers && \ sed -i '/secator ALL=(ALL) NOPASSWD: \/usr\/bin\/flock \/tmp\/install.lock ln -sf \/usr\/lib\/libpcap.so/d' /etc/sudoers; \ fi + +# full flavor: grant CAP_NET_RAW/CAP_NET_ADMIN directly on the raw-socket tool +# binaries instead of sudo (no baked sudoers entry to abuse via GTFObins). +RUN if [ "$flavor" != "lite" ]; then \ + apk add --no-cache libcap && \ + setcap cap_net_raw,cap_net_admin+eip /usr/bin/nmap && \ + setcap cap_net_raw,cap_net_admin+eip /usr/bin/arp-scan && \ + setcap cap_net_raw,cap_net_admin+eip /sbin/arp && \ + setcap cap_net_raw,cap_net_admin+eip /home/secator/.local/bin/naabu; \ + fi USER secator +# full flavor: switch secator to caps mode so requires_sudo commands run unprivileged. +RUN if [ "$flavor" != "lite" ]; then secator config set security.privileged_mode caps; fi + ENTRYPOINT ["secator"] diff --git a/secator/config.py b/secator/config.py index 6cf8962c8..75b777955 100644 --- a/secator/config.py +++ b/secator/config.py @@ -119,6 +119,7 @@ class Security(StrictModel): auto_install_commands: bool = True force_source_install: bool = False prompt_sudo_password: bool = True + privileged_mode: str = 'sudo' # 'sudo' (prepend sudo) or 'caps' (rely on file capabilities) class HTTP(StrictModel): diff --git a/secator/runners/command.py b/secator/runners/command.py index 329e1a695..ff1ef23c2 100644 --- a/secator/runners/command.py +++ b/secator/runners/command.py @@ -250,8 +250,8 @@ def __init__(self, inputs=[], **run_opts): # Run on_cmd hook self.run_hooks('on_cmd', sub='init') - # Add sudo to command if it is required - if self.requires_sudo: + # Add sudo to command if it is required (skip when relying on file capabilities instead) + if self.requires_sudo and CONFIG.security.privileged_mode != 'caps': self.cmd = f'sudo {self.cmd}' if self._has_sensitive_cmd_opts: self.cmd_redacted = f'sudo {self.cmd_redacted}' diff --git a/tests/unit/test_command.py b/tests/unit/test_command.py index 11e9b81e5..b1641cb0e 100644 --- a/tests/unit/test_command.py +++ b/tests/unit/test_command.py @@ -360,3 +360,41 @@ class TaskOverrideB(Command): self.assertEqual(cmd_b.input_chunk_size, 100) finally: CONFIG.tasks.overrides = original_overrides + + +class TestCommandPrivilegedMode(unittest.TestCase): + """Gate test for security.privileged_mode: 'caps' must not prepend sudo, 'sudo' (default) must.""" + + def test_sudo_mode_prepends_sudo(self): + from secator.config import CONFIG + + class SudoTestCmd(Command): + cmd = 'test' + requires_sudo = True + file_flag = None + + original_mode = CONFIG.security.privileged_mode + CONFIG.security.privileged_mode = 'sudo' + try: + with unittest.mock.patch('secator.runners.task.Task.get_task_class', return_value=SudoTestCmd): + cmd = SudoTestCmd(['target1']) + self.assertTrue(cmd.cmd.startswith('sudo ')) + finally: + CONFIG.security.privileged_mode = original_mode + + def test_caps_mode_does_not_prepend_sudo(self): + from secator.config import CONFIG + + class CapsTestCmd(Command): + cmd = 'test' + requires_sudo = True + file_flag = None + + original_mode = CONFIG.security.privileged_mode + CONFIG.security.privileged_mode = 'caps' + try: + with unittest.mock.patch('secator.runners.task.Task.get_task_class', return_value=CapsTestCmd): + cmd = CapsTestCmd(['target1']) + self.assertFalse(cmd.cmd.startswith('sudo ')) + finally: + CONFIG.security.privileged_mode = original_mode