diff options
-rw-r--r-- | .coveragerc | 11 | ||||
-rw-r--r-- | .dockerignore | 2 | ||||
-rw-r--r-- | Pipfile | 22 | ||||
-rw-r--r-- | Pipfile.lock | 128 | ||||
-rw-r--r-- | azure-pipelines.yml | 69 | ||||
-rw-r--r-- | docker-compose.yml | 3 | ||||
-rw-r--r-- | docker/venv.Dockerfile | 3 | ||||
-rw-r--r-- | scripts/.profile | 32 | ||||
-rwxr-xr-x | scripts/dev.sh | 63 | ||||
-rw-r--r-- | snekbox/nsjail.py | 42 | ||||
-rw-r--r-- | tests/test_nsjail.py | 124 | ||||
-rw-r--r-- | tests/test_snekbox.py | 56 |
12 files changed, 364 insertions, 191 deletions
diff --git a/.coveragerc b/.coveragerc index d1877d4..8490bab 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,9 @@ [run] branch = True -omit = - .venv/*, - snekbox/api/app.py, - tests/* +include = snekbox/* +omit = snekbox/api/app.py + +[report] +exclude_lines = + pragma: no cover + if DEBUG diff --git a/.dockerignore b/.dockerignore index 2a5ccec..afc786a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,9 @@ * # Make exceptions for what's needed +!docker/.profile !snekbox +!tests !Pipfile !Pipfile.lock !LICENSE @@ -9,9 +9,7 @@ gunicorn = "*" jsonschema = "*" [dev-packages] -pytest = "*" -pytest-cov = "*" -pytest-dependency = "*" +coverage = "*" pre-commit = "*" flake8 = "*" flake8-docstrings = "*" @@ -22,6 +20,7 @@ flake8-todo = "*" flake8-string-format = "*" flake8-formatter-junit-xml = "*" flake8-quotes = "*" +unittest-xml-reporting = "*" [requires] python_version = "3.7" @@ -29,17 +28,18 @@ python_version = "3.7" [scripts] lint = "flake8" precommit = "pre-commit install" -test = "pytest tests --cov . --cov-report term-missing -v" -report = "pytest tests --cov . --cov-report=html" +test = "scripts/dev.sh -c 'pipenv run coverage run -m unittest'" +report = "coverage html" snekbox = """ - gunicorn - -w 2 - -b 0.0.0.0:8060 - --logger-class snekbox.GunicornLogger - --access-logformat '%(m)s %(U)s%(q)s %(s)s %(b)s %(L)ss' - --access-logfile - + gunicorn \ + -w 2 \ + -b 0.0.0.0:8060 \ + --logger-class snekbox.GunicornLogger \ + --access-logformat '%(m)s %(U)s%(q)s %(s)s %(b)s %(L)ss' \ + --access-logfile - \ snekbox.api.app """ +devsh = "scripts/dev.sh" buildbox = "docker build -t pythondiscord/snekbox:latest -f docker/Dockerfile ." pushbox = "docker push pythondiscord/snekbox:latest" buildboxbase = "docker build -t pythondiscord/snekbox-base:latest -f docker/base.Dockerfile ." diff --git a/Pipfile.lock b/Pipfile.lock index c09c916..4f6bef8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "af53793d2c00001698021096041ed23e5de0a5553f974822e9ad7e58f70de4a9" + "sha256": "421cdae80970f990af48506e38464e4e2b99e20291070943027b86bd7ca29c5b" }, "pipfile-spec": 6, "requires": { @@ -81,13 +81,6 @@ ], "version": "==1.3.0" }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, "attrs": { "hashes": [ "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", @@ -136,6 +129,7 @@ "sha256:f8019c5279eb32360ca03e9fac40a12667715546eed5c5eb59eb381f2f501260", "sha256:fc5f4d209733750afd2714e9109816a29500718b32dd9a5db01c0cb3a019b96a" ], + "index": "pypi", "version": "==4.5.3" }, "entrypoints": { @@ -224,17 +218,17 @@ }, "identify": { "hashes": [ - "sha256:432c548d6138cb57a3d8f62f079a025a29b8ae34a50dd3b496bbf661818f2bc0", - "sha256:d4401d60bf1938aa3074a352a5cc9044107edf11a6fedd3a1db172c141619b81" + "sha256:0a11379b46d06529795442742a043dc2fa14cd8c995ae81d1febbc5f1c014c87", + "sha256:43a5d24ffdb07bc7e21faf68b08e9f526a1f41f0056073f480291539ef961dfd" ], - "version": "==1.4.3" + "version": "==1.4.5" }, "importlib-metadata": { "hashes": [ - "sha256:a9f185022cfa69e9ca5f7eabfd5a58b689894cb78a11e3c8c89398a8ccbb8e7f", - "sha256:df1403cd3aebeb2b1dcd3515ca062eecb5bd3ea7611f18cba81130c68707e879" + "sha256:6dfd58dfe281e8d240937776065dd3624ad5469c835248219bd16cf2e12dbeb7", + "sha256:cb6ee23b46173539939964df59d3d72c3e0c1b5d54b84f1d8a7e912fe43612db" ], - "version": "==0.17" + "version": "==0.18" }, "junit-xml": { "hashes": [ @@ -249,48 +243,19 @@ ], "version": "==0.6.1" }, - "more-itertools": { - "hashes": [ - "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", - "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" - ], - "markers": "python_version > '2.7'", - "version": "==7.0.0" - }, "nodeenv": { "hashes": [ "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a" ], "version": "==1.3.3" }, - "packaging": { - "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" - ], - "version": "==19.0" - }, - "pluggy": { - "hashes": [ - "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", - "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c" - ], - "version": "==0.12.0" - }, "pre-commit": { "hashes": [ - "sha256:6ca409d1f22d444af427fb023a33ca8b69625d508a50e1b7eaabd59247c93043", - "sha256:94dd519597f5bff06a4b0df194a79c524b78f4b1534c1ce63241a9d4fb23b926" + "sha256:92e406d556190503630fd801958379861c94884693a032ba66629d0351fdccd4", + "sha256:cccc39051bc2457b0c0f7152a411f8e05e3ba2fe1a5613e4ee0833c1c1985ce3" ], "index": "pypi", - "version": "==1.16.1" - }, - "py": { - "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" - ], - "version": "==1.8.0" + "version": "==1.17.0" }, "pycodestyle": { "hashes": [ @@ -314,51 +279,21 @@ ], "version": "==2.1.1" }, - "pyparsing": { - "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" - ], - "version": "==2.4.0" - }, - "pytest": { - "hashes": [ - "sha256:6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee", - "sha256:bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322" - ], - "index": "pypi", - "version": "==4.6.2" - }, - "pytest-cov": { - "hashes": [ - "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", - "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a" - ], - "index": "pypi", - "version": "==2.7.1" - }, - "pytest-dependency": { - "hashes": [ - "sha256:bda0ef48e6a44c091399b12ab4a7e580d2dd8294c222b301f88d7d57f47ba142" - ], - "index": "pypi", - "version": "==0.4.0" - }, "pyyaml": { "hashes": [ - "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", - "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", - "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", - "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", - "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", - "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", - "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", - "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", - "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", - "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", - "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" + "sha256:57acc1d8533cbe51f6662a55434f0dbecfa2b9eaf115bede8f6fd00115a0c0d3", + "sha256:588c94b3d16b76cfed8e0be54932e5729cc185caffaa5a451e7ad2f7ed8b4043", + "sha256:68c8dd247f29f9a0d09375c9c6b8fdc64b60810ebf07ba4cdd64ceee3a58c7b7", + "sha256:70d9818f1c9cd5c48bb87804f2efc8692f1023dac7f1a1a5c61d454043c1d265", + "sha256:86a93cccd50f8c125286e637328ff4eef108400dd7089b46a7be3445eecfa391", + "sha256:a0f329125a926876f647c9fa0ef32801587a12328b4a3c741270464e3e4fa778", + "sha256:a3c252ab0fa1bb0d5a3f6449a4826732f3eb6c0270925548cac342bc9b22c225", + "sha256:b4bb4d3f5e232425e25dda21c070ce05168a786ac9eda43768ab7f3ac2770955", + "sha256:cd0618c5ba5bda5f4039b9398bb7fb6a317bb8298218c3de25c47c4740e4b95e", + "sha256:ceacb9e5f8474dcf45b940578591c7f3d960e82f926c707788a570b51ba59190", + "sha256:fe6a88094b64132c4bb3b631412e90032e8cfe9745a58370462240b8cb7553cd" ], - "version": "==5.1" + "version": "==5.1.1" }, "six": { "hashes": [ @@ -381,19 +316,20 @@ ], "version": "==0.10.0" }, - "virtualenv": { + "unittest-xml-reporting": { "hashes": [ - "sha256:99acaf1e35c7ccf9763db9ba2accbca2f4254d61d1912c5ee364f9cc4a8942a0", - "sha256:fe51cdbf04e5d8152af06c075404745a7419de27495a83f0d72518ad50be3ce8" + "sha256:140982e4b58e4052d9ecb775525b246a96bfc1fc26097806e05ea06e9166dd6c", + "sha256:d1fbc7a1b6c6680ccfe75b5e9701e5431c646970de049e687b4bb35ba4325d72" ], - "version": "==16.6.0" + "index": "pypi", + "version": "==2.5.1" }, - "wcwidth": { + "virtualenv": { "hashes": [ - "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", - "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" + "sha256:b7335cddd9260a3dd214b73a2521ffc09647bde3e9457fcca31dc3be3999d04a", + "sha256:d28ca64c0f3f125f59cabf13e0a150e1c68e5eea60983cc4395d88c584495783" ], - "version": "==0.1.7" + "version": "==16.6.1" }, "zipp": { "hashes": [ diff --git a/azure-pipelines.yml b/azure-pipelines.yml index bd916a4..98d64bf 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -2,32 +2,73 @@ jobs: - job: test - displayName: 'Lint' + displayName: 'Lint & Test' pool: vmImage: 'Ubuntu-16.04' steps: - - task: UsePythonVersion@0 - displayName: 'Set Python version' - inputs: - versionSpec: '3.7.x' - addToPath: true + - script: docker build -t pythondiscord/snekbox-base:latest -f docker/base.Dockerfile . + displayName: 'Build Base Image' + + - script: | + docker build -t pythondiscord/snekbox-venv:dev -f docker/venv.Dockerfile --build-arg DEV=1 . + displayName: 'Build Development Image' + + - script: | + docker run \ + -td \ + --name snekbox_test \ + --privileged \ + --network host \ + -h pdsnk-dev \ + -e PYTHONDONTWRITEBYTECODE=1 \ + -e PIPENV_PIPFILE="/snekbox/Pipfile" \ + -e ENV="${PWD}/scripts/.profile" \ + -v "${PWD}":"${PWD}" \ + -w "${PWD}"\ + --entrypoint /bin/ash \ + pythondiscord/snekbox-venv:dev + displayName: 'Start Container' + + - script: | + docker exec snekbox_test /bin/ash -c \ + 'pipenv run lint --format junit-xml --output-file test-lint.xml' + displayName: 'Run Linter' - - script: pip3 install pipenv - displayName: 'Install pipenv' + - task: PublishTestResults@2 + condition: succeededOrFailed() + displayName: 'Publish Lint Results' + inputs: + testResultsFiles: '**/test-lint.xml' + testRunTitle: 'Lint Results' - - script: pipenv install --dev --deploy --system - displayName: 'Install project using pipenv' + - script: sudo swapoff -a + displayName: 'Disable swap memory' - - script: python3 -m flake8 --format junit-xml --output-file test-lint.xml - displayName: 'Run linter' + - script: | + docker exec snekbox_test /bin/ash -c \ + 'pipenv run coverage run -m xmlrunner' + displayName: 'Run Unit Tests' - task: PublishTestResults@2 condition: succeededOrFailed() + displayName: 'Publish Test Results' + inputs: + testResultsFiles: '**/TEST-*.xml' + testRunTitle: 'Test Results' + + - script: | + docker exec snekbox_test /bin/ash -c \ + 'pipenv run coverage xml' + displayName: 'Generate Coverage Report' + + - task: PublishCodeCoverageResults@1 + displayName: 'Publish Coverage Results' + condition: succeededOrFailed() inputs: - testResultsFiles: '**/test-*.xml' - testRunTitle: 'Snekbox Flake8 Lint Results' + codeCoverageTool: Cobertura + summaryFileLocation: '**/coverage.xml' - job: build displayName: 'Build' diff --git a/docker-compose.yml b/docker-compose.yml index 1fe8e39..d071a71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,3 +6,6 @@ services: image: pythondiscord/snekbox:latest network_mode: "host" init: true + build: + context: . + dockerfile: docker/Dockerfile diff --git a/docker/venv.Dockerfile b/docker/venv.Dockerfile index 61aba58..85188fd 100644 --- a/docker/venv.Dockerfile +++ b/docker/venv.Dockerfile @@ -1,5 +1,6 @@ FROM pythondiscord/snekbox-base:latest +ARG DEV ENV PIP_NO_CACHE_DIR=false \ PIPENV_DONT_USE_PYENV=1 \ PIPENV_HIDE_EMOJIS=1 \ @@ -9,4 +10,4 @@ ENV PIP_NO_CACHE_DIR=false \ COPY Pipfile Pipfile.lock /snekbox/ WORKDIR /snekbox -RUN pipenv sync +RUN if [ -n "${DEV}" ]; pipenv sync --dev; then pipenv sync; fi diff --git a/scripts/.profile b/scripts/.profile new file mode 100644 index 0000000..bff260d --- /dev/null +++ b/scripts/.profile @@ -0,0 +1,32 @@ +nsjpy() { + local MEM_MAX=52428800 + + # All arguments except the last are considered to be for NsJail, not Python. + local nsj_args="" + while [ "$#" -gt 1 ]; do + nsj_args="${nsj_args:+${nsj_args} }$1" + shift + done + + # Set up cgroups and disable memory swapping. + mkdir -p /sys/fs/cgroup/pids/NSJAIL + mkdir -p /sys/fs/cgroup/memory/NSJAIL + echo "${MEM_MAX}" > /sys/fs/cgroup/memory/NSJAIL/memory.limit_in_bytes + echo "${MEM_MAX}" > /sys/fs/cgroup/memory/NSJAIL/memory.memsw.limit_in_bytes + + nsjail \ + -Mo \ + --rlimit_as 700 \ + --chroot / \ + -E LANG=en_US.UTF-8 \ + -R/usr -R/lib -R/lib64 \ + --user nobody \ + --group nogroup \ + --time_limit 2 \ + --disable_proc \ + --iface_no_lo \ + --cgroup_pids_max=1 \ + --cgroup_mem_max="${MEM_MAX}" \ + $nsj_args -- \ + /snekbox/.venv/bin/python3 -Iq -c "$@" +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..097690b --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env sh + +# Sets up a development environment and runs a shell in a docker container. +# Usage: dev.sh [--build [--clean]] [ash_args ...] + +if [ "$1" = "--build" ]; then + shift + printf "Building pythondiscord/snekbox-venv:dev..." + + docker build \ + -t pythondiscord/snekbox-venv:dev \ + -f docker/venv.Dockerfile \ + --build-arg DEV=1 \ + -q \ + . \ + >/dev/null \ + && printf " done!\n" || exit "$?" + + if [ "$1" = "--clean" ]; then + shift + dangling_imgs=$(docker images -f "dangling=true" -q) + + if [ -n "${dangling_imgs}" ]; then + printf "Removing dangling images..." + + docker rmi $dangling_imgs >/dev/null \ + && printf " done!\n" || exit "$?" + fi + fi +fi + +# Keep the container up in the background so it doesn't have to be restarted +# for the ownership fix. +# The volume is mounted to same the path in the container as the source +# directory on the host to ensure coverage can find the source files. +docker run \ + -td \ + --name snekbox_test \ + --privileged \ + --network host \ + -h pdsnk-dev \ + -e PYTHONDONTWRITEBYTECODE=1 \ + -e PIPENV_PIPFILE="/snekbox/Pipfile" \ + -e ENV="${PWD}/scripts/.profile" \ + -v "${PWD}":"${PWD}" \ + -w "${PWD}"\ + --entrypoint /bin/ash \ + pythondiscord/snekbox-venv:dev \ + >/dev/null \ + +# Execute the given command(s) +docker exec -it snekbox_test /bin/ash "$@" + +# Fix ownership of coverage file +# BusyBox doesn't support --reference for chown +docker exec \ + -it \ + -e CWD="${PWD}" \ + snekbox_test \ + /bin/ash \ + -c 'chown "$(stat -c "%u:%g" "${CWD}")" "${CWD}/.coverage"' + +docker rm -f snekbox_test >/dev/null # Stop and remove the container diff --git a/snekbox/nsjail.py b/snekbox/nsjail.py index b1dc34d..d980f09 100644 --- a/snekbox/nsjail.py +++ b/snekbox/nsjail.py @@ -5,7 +5,9 @@ import subprocess import sys import textwrap from pathlib import Path +from subprocess import CompletedProcess from tempfile import NamedTemporaryFile +from typing import Iterable from snekbox import DEBUG @@ -13,7 +15,7 @@ log = logging.getLogger(__name__) # [level][timestamp][PID]? function_signature:line_no? message LOG_PATTERN = re.compile( - r"\[(?P<level>(I)|[WEF])\]\[.+?\](?(2)|(?P<func>\[\d+\] .+?:\d+ )) ?(?P<msg>.+)" + r"\[(?P<level>(I)|[DWEF])\]\[.+?\](?(2)|(?P<func>\[\d+\] .+?:\d+ )) ?(?P<msg>.+)" ) LOG_BLACKLIST = ("Process will be ",) @@ -22,6 +24,7 @@ CGROUP_PIDS_PARENT = Path("/sys/fs/cgroup/pids/NSJAIL") CGROUP_MEMORY_PARENT = Path("/sys/fs/cgroup/memory/NSJAIL") NSJAIL_PATH = os.getenv("NSJAIL_PATH", "/usr/sbin/nsjail") +MEM_MAX = 52428800 class NsJail: @@ -57,14 +60,30 @@ class NsJail: NsJail doesn't do this automatically because it requires privileges NsJail usually doesn't have. + + Disables memory swapping. """ pids.mkdir(parents=True, exist_ok=True) mem.mkdir(parents=True, exist_ok=True) + # Swap limit cannot be set to a value lower than memory.limit_in_bytes. + # Therefore, this must be set first. + (mem / "memory.limit_in_bytes").write_text(str(MEM_MAX), encoding="utf-8") + + try: + # Swap limit is specified as the sum of the memory and swap limits. + (mem / "memory.memsw.limit_in_bytes").write_text(str(MEM_MAX), encoding="utf-8") + except PermissionError: + log.warning( + "Failed to set the memory swap limit for the cgroup. " + "This is probably because CONFIG_MEMCG_SWAP or CONFIG_MEMCG_SWAP_ENABLED is unset. " + "Please ensure swap memory is disabled on the system." + ) + @staticmethod - def _parse_log(log_file): + def _parse_log(log_lines: Iterable[str]): """Parse and log NsJail's log messages.""" - for line in log_file.read().decode("UTF-8").splitlines(): + for line in log_lines: match = LOG_PATTERN.fullmatch(line) if match is None: log.warning(f"Failed to parse log line '{line}'") @@ -91,7 +110,7 @@ class NsJail: # Treat fatal as error. log.error(msg) - def python3(self, code: str) -> subprocess.CompletedProcess: + def python3(self, code: str) -> CompletedProcess: """Execute Python 3 code in an isolated environment and return the completed process.""" with NamedTemporaryFile() as nsj_log: args = ( @@ -100,13 +119,13 @@ class NsJail: "--chroot", "/", "-E", "LANG=en_US.UTF-8", "-R/usr", "-R/lib", "-R/lib64", - "--user", "nobody", - "--group", "nogroup", + "--user", "65534", # nobody + "--group", "65534", # nobody/nogroup "--time_limit", "2", "--disable_proc", "--iface_no_lo", "--log", nsj_log.name, - "--cgroup_mem_max=52428800", + f"--cgroup_mem_max={MEM_MAX}", "--cgroup_mem_mount", str(CGROUP_MEMORY_PARENT.parent), "--cgroup_mem_parent", CGROUP_MEMORY_PARENT.name, "--cgroup_pids_max=1", @@ -129,8 +148,13 @@ class NsJail: text=True ) except ValueError: - return subprocess.CompletedProcess(args, None, "ValueError: embedded null byte", "") + return CompletedProcess(args, None, "ValueError: embedded null byte", None) + + log_lines = nsj_log.read().decode("utf-8").splitlines() + if not log_lines and result.returncode == 255: + # NsJail probably failed to parse arguments so log output will still be in stdout + log_lines = result.stdout.splitlines() - self._parse_log(nsj_log) + self._parse_log(log_lines) return result diff --git a/tests/test_nsjail.py b/tests/test_nsjail.py new file mode 100644 index 0000000..bb176d9 --- /dev/null +++ b/tests/test_nsjail.py @@ -0,0 +1,124 @@ +import logging +import unittest +from textwrap import dedent + +from snekbox.nsjail import MEM_MAX, NsJail + + +class NsJailTests(unittest.TestCase): + def setUp(self): + super().setUp() + + self.nsjail = NsJail() + self.nsjail.DEBUG = False + self.logger = logging.getLogger("snekbox.nsjail") + + def test_print_returns_0(self): + result = self.nsjail.python3("print('test')") + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, "test\n") + self.assertEqual(result.stderr, None) + + def test_timeout_returns_137(self): + code = dedent(""" + while True: + pass + """).strip() + + with self.assertLogs(self.logger) as log: + result = self.nsjail.python3(code) + + self.assertEqual(result.returncode, 137) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, None) + self.assertIn("run time >= time limit", "\n".join(log.output)) + + def test_memory_returns_137(self): + # Add a kilobyte just to be safe. + code = dedent(f""" + x = ' ' * {MEM_MAX + 1000} + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 137) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, None) + + def test_subprocess_resource_unavailable(self): + code = dedent(""" + import subprocess + print(subprocess.check_output('kill -9 6', shell=True).decode()) + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 1) + self.assertIn("Resource temporarily unavailable", result.stdout) + self.assertEqual(result.stderr, None) + + def test_read_only_file_system(self): + code = dedent(""" + open('hello', 'w').write('world') + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 1) + self.assertIn("Read-only file system", result.stdout) + self.assertEqual(result.stderr, None) + + def test_forkbomb_resource_unavailable(self): + code = dedent(""" + import os + while 1: + os.fork() + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 1) + self.assertIn("Resource temporarily unavailable", result.stdout) + self.assertEqual(result.stderr, None) + + def test_sigsegv_returns_139(self): # In honour of Juan. + code = dedent(""" + import ctypes + ctypes.string_at(0) + """).strip() + + result = self.nsjail.python3(code) + self.assertEqual(result.returncode, 139) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, None) + + def test_null_byte_value_error(self): + result = self.nsjail.python3("\0") + self.assertEqual(result.returncode, None) + self.assertEqual(result.stdout, "ValueError: embedded null byte") + self.assertEqual(result.stderr, None) + + def test_log_parser(self): + log_lines = ( + "[D][2019-06-22T20:07:00+0000][16] void foo::bar()():100 This is a debug message.", + "[I][2019-06-22T20:07:48+0000] pid=20 ([STANDALONE MODE]) " + "exited with status: 2, (PIDs left: 0)", + "[W][2019-06-22T20:06:04+0000][14] void cmdline::logParams(nsjconf_t*)():250 " + "Process will be UID/EUID=0 in the global user namespace, and will have user " + "root-level access to files", + "[W][2019-06-22T20:07:00+0000][16] void foo::bar()():100 This is a warning!", + "[E][2019-06-22T20:07:00+0000][16] bool " + "cmdline::setupArgv(nsjconf_t*, int, char**, int)():316 No command-line provided", + "[F][2019-06-22T20:07:00+0000][16] int main(int, char**)():204 " + "Couldn't parse cmdline options", + "Invalid Line" + ) + + with self.assertLogs(self.logger, logging.DEBUG) as log: + self.nsjail._parse_log(log_lines) + + self.assertIn("DEBUG:snekbox.nsjail:This is a debug message.", log.output) + self.assertIn("ERROR:snekbox.nsjail:Couldn't parse cmdline options", log.output) + self.assertIn("ERROR:snekbox.nsjail:No command-line provided", log.output) + self.assertIn("WARNING:snekbox.nsjail:Failed to parse log line 'Invalid Line'", log.output) + self.assertIn("WARNING:snekbox.nsjail:This is a warning!", log.output) + self.assertIn( + "INFO:snekbox.nsjail:pid=20 ([STANDALONE MODE]) exited with status: 2, (PIDs left: 0)", + log.output + ) diff --git a/tests/test_snekbox.py b/tests/test_snekbox.py deleted file mode 100644 index 46319d6..0000000 --- a/tests/test_snekbox.py +++ /dev/null @@ -1,56 +0,0 @@ -import unittest - -from snekbox.nsjail import NsJail - -nsjail = NsJail() - - -class SnekTests(unittest.TestCase): - def test_nsjail(self): - result = nsjail.python3("print('test')") - self.assertEquals(result.strip(), "test") - - # def test_memory_error(self): - # code = ("x = "*"\n" - # "while True:\n" - # " x = x * 99\n") - # result = nsjail.python3(code) - # self.assertEquals(result.strip(), "timed out or memory limit exceeded") - - def test_timeout(self): - code = ( - "x = '*'\n" - "while True:\n" - " try:\n" - " x = x * 99\n" - " except:\n" - " continue\n" - ) - - result = nsjail.python3(code) - self.assertEquals(result.strip(), "timed out or memory limit exceeded") - - def test_kill(self): - code = ("import subprocess\n" - "print(subprocess.check_output('kill -9 6', shell=True).decode())") - result = nsjail.python3(code) - if "ModuleNotFoundError" in result.strip(): - self.assertIn("ModuleNotFoundError", result.strip()) - else: - self.assertIn("(PIDs left: 0)", result.strip()) - - def test_forkbomb(self): - code = ("import os\n" - "while 1:\n" - " os.fork()") - result = nsjail.python3(code) - self.assertIn("Resource temporarily unavailable", result.strip()) - - def test_juan_golf(self): # in honour of Juan - code = ("func = lambda: None\n" - "CodeType = type(func.__code__)\n" - "bytecode = CodeType(0,1,0,0,0,b'',(),(),(),'','',1,b'')\n" - "exec(bytecode)") - - result = nsjail.python3(code) - self.assertEquals("unknown error, code: 111", result.strip()) |