|
1 | 1 | """Tests for argus.core.engine — ArgusEngine.""" |
2 | 2 |
|
| 3 | +import os |
3 | 4 | import subprocess |
4 | 5 | from pathlib import Path |
5 | 6 |
|
@@ -719,6 +720,91 @@ def mock_run(cmd, **kwargs): |
719 | 720 | assert result.findings[0].title == "from stdout" |
720 | 721 | assert captured_file["path"].name == "stdout.txt" |
721 | 722 |
|
| 723 | + def test_container_output_dir_is_world_writable(self, monkeypatch): |
| 724 | + """Regression: scanners running as USER non-root (uid 1000) |
| 725 | + couldn't write /output/results.json on hosts with uid != 1000 |
| 726 | + because Python's TemporaryDirectory creates dirs mode 0o700. |
| 727 | + Engine now chmods the dir 0o777 right after creation.""" |
| 728 | + engine = self._make_engine() |
| 729 | + scanner = self._make_scanner() |
| 730 | + |
| 731 | + monkeypatch.setattr(engine, "_pull_image", lambda img: True) |
| 732 | + monkeypatch.setattr(engine, "_get_image_digest", lambda img: "sha256:abc") |
| 733 | + |
| 734 | + captured_mode = {} |
| 735 | + |
| 736 | + def mock_run(cmd, **_kwargs): |
| 737 | + # The chmod happens before docker run, so by the time we're |
| 738 | + # invoked the host-side temp dir already has the new mode. |
| 739 | + for i, arg in enumerate(cmd): |
| 740 | + if ":/output" in str(arg): |
| 741 | + host_dir = arg.split(":")[0] |
| 742 | + captured_mode["mode"] = os.stat(host_dir).st_mode & 0o777 |
| 743 | + Path(host_dir).joinpath("results.json").write_text("{}") |
| 744 | + break |
| 745 | + return subprocess.CompletedProcess( |
| 746 | + args=cmd, returncode=0, stdout="", stderr="", |
| 747 | + ) |
| 748 | + |
| 749 | + monkeypatch.setattr(subprocess, "run", mock_run) |
| 750 | + |
| 751 | + engine._run_in_container(scanner, "/src", {}) |
| 752 | + # 0o777 means rwx for owner, group, other — every container |
| 753 | + # uid can write to /output regardless of its image's USER. |
| 754 | + assert captured_mode["mode"] == 0o777 |
| 755 | + |
| 756 | + def test_container_no_output_marks_execution_failed(self, monkeypatch): |
| 757 | + """Empty result_files + no stdout means the container ran but |
| 758 | + produced nothing. Mark the ScanResult so reporters and the |
| 759 | + --fail-on-scanner-error gate can surface it instead of silently |
| 760 | + rolling it up as an empty PASS.""" |
| 761 | + engine = self._make_engine() |
| 762 | + scanner = self._make_scanner() |
| 763 | + |
| 764 | + monkeypatch.setattr(engine, "_pull_image", lambda img: True) |
| 765 | + monkeypatch.setattr(engine, "_get_image_digest", lambda img: "sha256:abc") |
| 766 | + |
| 767 | + def mock_run(cmd, **_kwargs): |
| 768 | + # Container exits 13 (permission denied) without writing |
| 769 | + # anything to /output — the exact bug in the user report. |
| 770 | + return subprocess.CompletedProcess( |
| 771 | + args=cmd, returncode=13, stdout="", |
| 772 | + stderr="cannot open /output/results.json: permission denied", |
| 773 | + ) |
| 774 | + |
| 775 | + monkeypatch.setattr(subprocess, "run", mock_run) |
| 776 | + |
| 777 | + result = engine._run_in_container(scanner, "/src", {}) |
| 778 | + assert result.findings == [] |
| 779 | + assert result.metadata.get("execution_failed") is True |
| 780 | + # Reason carries the stderr for the user — they shouldn't have |
| 781 | + # to bump the log level to find out why. |
| 782 | + reason = result.metadata.get("execution_failure_reason", "") |
| 783 | + assert "permission denied" in reason |
| 784 | + assert "exit=13" in reason |
| 785 | + |
| 786 | + def test_container_with_output_does_not_mark_execution_failed(self, monkeypatch): |
| 787 | + """Successful container runs must not get the failure marker.""" |
| 788 | + engine = self._make_engine() |
| 789 | + scanner = self._make_scanner(parse_results=lambda f: []) |
| 790 | + |
| 791 | + monkeypatch.setattr(engine, "_pull_image", lambda img: True) |
| 792 | + monkeypatch.setattr(engine, "_get_image_digest", lambda img: "sha256:abc") |
| 793 | + |
| 794 | + def mock_run(cmd, **_kwargs): |
| 795 | + for i, arg in enumerate(cmd): |
| 796 | + if ":/output" in str(arg): |
| 797 | + Path(arg.split(":")[0]).joinpath("results.json").write_text("[]") |
| 798 | + break |
| 799 | + return subprocess.CompletedProcess( |
| 800 | + args=cmd, returncode=0, stdout="", stderr="", |
| 801 | + ) |
| 802 | + |
| 803 | + monkeypatch.setattr(subprocess, "run", mock_run) |
| 804 | + |
| 805 | + result = engine._run_in_container(scanner, "/src", {}) |
| 806 | + assert "execution_failed" not in result.metadata |
| 807 | + |
722 | 808 | def test_container_custom_entrypoint(self, monkeypatch): |
723 | 809 | engine = self._make_engine() |
724 | 810 | scanner = self._make_scanner(container_entrypoint="/bin/custom") |
|
0 commit comments