ci: mount audit workspace safely
This commit is contained in:
248
tests/test_security_audit_mount_resolver.py
Normal file
248
tests/test_security_audit_mount_resolver.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
RESOLVER = (
|
||||
META_ROOT / "tools" / "checks" / "security-audit" / "resolve_workspace_mount.py"
|
||||
)
|
||||
|
||||
|
||||
def load_resolver_module():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"security_audit_mount_resolver",
|
||||
RESOLVER,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {RESOLVER}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SecurityAuditMountResolverTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.resolver = load_resolver_module()
|
||||
self.root = "/workspace/add-ideas/govoplan/govoplan"
|
||||
self.workspace = "/workspace/add-ideas/govoplan"
|
||||
|
||||
def test_resolves_only_the_named_workspace_volume_for_both_scopes(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/var/run/docker.sock",
|
||||
"Destination": "/var/run/docker.sock",
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-workspace",
|
||||
"Source": "/var/lib/docker/volumes/actions-workspace/_data",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-environment",
|
||||
"Source": "/var/lib/docker/volumes/actions-environment/_data",
|
||||
"Destination": "/var/run/act",
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
|
||||
for scope in ("current", "govoplan"):
|
||||
with self.subTest(scope=scope):
|
||||
self.assertEqual(
|
||||
(f"type=volume,source=actions-workspace,target={self.workspace}"),
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope=scope,
|
||||
reports_dir="audit-reports",
|
||||
),
|
||||
)
|
||||
|
||||
def test_resolves_a_workspace_bind_without_other_mounts(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/srv/gitea/actions/task-123",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/var/run/docker.sock",
|
||||
"Destination": "/var/run/docker.sock",
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
(f"type=bind,source=/srv/gitea/actions/task-123,target={self.workspace}"),
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
),
|
||||
)
|
||||
|
||||
def test_nested_repository_mount_is_valid_only_for_current_scope(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "all-repositories",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "current-repository",
|
||||
"Destination": self.root,
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
|
||||
self.assertIn(
|
||||
"source=current-repository",
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="current",
|
||||
reports_dir="audit-reports",
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
self.resolver.MountResolutionError,
|
||||
"nested job-container mounts",
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
def test_rejects_unsafe_or_unusable_mount_layouts(self) -> None:
|
||||
cases = {
|
||||
"missing": [],
|
||||
"path-boundary": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "wrong-workspace",
|
||||
"Destination": "/workspace/add-ideas/govoplan-other",
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
"read-only": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-workspace",
|
||||
"Destination": self.workspace,
|
||||
"RW": False,
|
||||
}
|
||||
],
|
||||
"ambiguous": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": name,
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
for name in ("workspace-one", "workspace-two")
|
||||
],
|
||||
"scope-too-narrow": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "repository-only",
|
||||
"Destination": self.root,
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
for name, mounts in cases.items():
|
||||
with (
|
||||
self.subTest(name=name),
|
||||
self.assertRaises(self.resolver.MountResolutionError),
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
def test_rejects_reports_outside_the_selected_workspace_mount(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "actions-workspace",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
]
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.resolver.MountResolutionError,
|
||||
"reports path .* outside",
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
mounts,
|
||||
root=self.root,
|
||||
scope="current",
|
||||
reports_dir="/tmp/audit-reports",
|
||||
)
|
||||
|
||||
def test_rejects_broad_or_sensitive_workspace_bind_sources(self) -> None:
|
||||
for source in (
|
||||
"/",
|
||||
"/home",
|
||||
"/var/run/docker.sock",
|
||||
"/srv/../etc/shadow",
|
||||
):
|
||||
with (
|
||||
self.subTest(source=source),
|
||||
self.assertRaisesRegex(
|
||||
self.resolver.MountResolutionError,
|
||||
"too broad or sensitive",
|
||||
),
|
||||
):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
[
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": source,
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
with self.assertRaises(self.resolver.MountResolutionError):
|
||||
self.resolver.resolve_workspace_mount(
|
||||
[
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "//var/lib/workspace",
|
||||
"Destination": self.workspace,
|
||||
"RW": True,
|
||||
}
|
||||
],
|
||||
root=self.root,
|
||||
scope="govoplan",
|
||||
reports_dir="audit-reports",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,6 +12,7 @@ import unittest
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
AUDIT_SCRIPT = META_ROOT / "tools" / "checks" / "check-security-audit.sh"
|
||||
CONTAINER_RUNNER = META_ROOT / "tools" / "checks" / "security-audit" / "run.sh"
|
||||
|
||||
|
||||
class SecurityAuditWrapperTests(unittest.TestCase):
|
||||
@@ -354,5 +355,162 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
||||
self.assertEqual(set(manifest["reports"]), checksummed_paths)
|
||||
|
||||
|
||||
class SecurityAuditContainerRunnerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._temporary_directory = tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-audit-runner-"
|
||||
)
|
||||
root = Path(self._temporary_directory.name)
|
||||
self.stub_bin = root / "bin"
|
||||
self.stub_bin.mkdir()
|
||||
self.docker_log = root / "docker.jsonl"
|
||||
docker_stub = self.stub_bin / "docker"
|
||||
docker_stub.write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
arguments = sys.argv[1:]
|
||||
with Path(os.environ["DOCKER_STUB_LOG"]).open(
|
||||
"a", encoding="utf-8"
|
||||
) as handle:
|
||||
handle.write(json.dumps(arguments) + "\\n")
|
||||
if arguments and arguments[0] == "version":
|
||||
print("26.1.0")
|
||||
if arguments[:2] == ["container", "inspect"]:
|
||||
print(os.environ["DOCKER_STUB_MOUNTS"])
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
docker_stub.chmod(0o755)
|
||||
self.environment = os.environ.copy()
|
||||
self.environment.update(
|
||||
{
|
||||
"DOCKER_STUB_LOG": str(self.docker_log),
|
||||
"DOCKER_STUB_MOUNTS": "[]",
|
||||
"PATH": f"{self.stub_bin}:{self.environment['PATH']}",
|
||||
}
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
actions_mounts: object | None = None,
|
||||
) -> tuple[subprocess.CompletedProcess[str], list[list[str]]]:
|
||||
self.docker_log.write_text("", encoding="utf-8")
|
||||
environment = self.environment.copy()
|
||||
if actions_mounts is None:
|
||||
environment.pop("GITEA_ACTIONS", None)
|
||||
else:
|
||||
environment["GITEA_ACTIONS"] = "true"
|
||||
environment["DOCKER_STUB_MOUNTS"] = json.dumps(actions_mounts)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(CONTAINER_RUNNER),
|
||||
"--mode",
|
||||
"quick",
|
||||
"--scope",
|
||||
scope,
|
||||
],
|
||||
cwd=META_ROOT,
|
||||
env=environment,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
commands = [
|
||||
json.loads(line)
|
||||
for line in self.docker_log.read_text(encoding="utf-8").splitlines()
|
||||
]
|
||||
return result, commands
|
||||
|
||||
def test_gitea_job_shares_only_its_workspace_mount(self) -> None:
|
||||
mounts = [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/var/run/docker.sock",
|
||||
"Destination": "/var/run/docker.sock",
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "govoplan-actions-workspace",
|
||||
"Destination": str(META_ROOT.parent),
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "govoplan-actions-environment",
|
||||
"Destination": "/var/run/act",
|
||||
"RW": True,
|
||||
},
|
||||
]
|
||||
container_id = subprocess.run(
|
||||
["hostname"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
for scope in ("current", "govoplan"):
|
||||
with self.subTest(scope=scope):
|
||||
result, commands = self._run(scope=scope, actions_mounts=mounts)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
run_command = next(
|
||||
command for command in commands if command[0] == "run"
|
||||
)
|
||||
inspect_command = next(
|
||||
command
|
||||
for command in commands
|
||||
if command[:2] == ["container", "inspect"]
|
||||
)
|
||||
self.assertEqual(container_id, inspect_command[-1])
|
||||
mount_index = run_command.index("--mount")
|
||||
self.assertEqual(
|
||||
(
|
||||
"type=volume,source=govoplan-actions-workspace,"
|
||||
f"target={META_ROOT.parent}"
|
||||
),
|
||||
run_command[mount_index + 1],
|
||||
)
|
||||
self.assertNotIn("--volumes-from", run_command)
|
||||
self.assertNotIn("-v", run_command)
|
||||
self.assertNotIn("/var/run/docker.sock", " ".join(run_command))
|
||||
self.assertNotIn("/var/run/act", " ".join(run_command))
|
||||
repository_roots = [
|
||||
value
|
||||
for value in run_command
|
||||
if value.startswith("GOVOPLAN_REPOS_ROOT=")
|
||||
]
|
||||
expected_roots = (
|
||||
[f"GOVOPLAN_REPOS_ROOT={META_ROOT.parent}"]
|
||||
if scope == "govoplan"
|
||||
else []
|
||||
)
|
||||
self.assertEqual(expected_roots, repository_roots)
|
||||
|
||||
def test_non_actions_runner_keeps_the_scoped_bind_mount(self) -> None:
|
||||
result, commands = self._run(scope="govoplan")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
run_command = next(command for command in commands if command[0] == "run")
|
||||
bind_index = run_command.index("-v")
|
||||
self.assertEqual(
|
||||
f"{META_ROOT.parent}:/workspace",
|
||||
run_command[bind_index + 1],
|
||||
)
|
||||
self.assertNotIn("--mount", run_command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user