diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md index db0fe7b..86bf468 100644 --- a/docs/SECURITY_AUDIT.md +++ b/docs/SECURITY_AUDIT.md @@ -164,10 +164,12 @@ The regular `Security Audit` workflow reuses the fingerprinted toolbox image when the Docker daemon is persistent, which is the normal case for the self-hosted Gitea runner using the host Docker socket. Trusted push, schedule, and manual runs scan all registered repositories; authenticated SSH is used -only for the private website repository. Pull-request audit runs stay disabled -while the audit runner exposes its host Docker socket: PR-controlled audit code -must run on a disposable or rootless runner without host-socket access. The -separate +only for the private website repository. The wrapper inspects the Actions job +mount table and forwards only the narrowest writable mount covering the audit +scope; it never inherits the job's Docker socket or unrelated runner mounts. +Pull-request audit runs stay disabled while the audit runner exposes its host +Docker socket: PR-controlled audit code must run on a disposable or rootless +runner without host-socket access. The separate `Security Audit Toolbox Update` workflow runs weekly with `SECURITY_AUDIT_UPDATE=1`; it pulls current base images and re-resolves the allowed tool version ranges into a refreshed local image. diff --git a/tests/test_security_audit_mount_resolver.py b/tests/test_security_audit_mount_resolver.py new file mode 100644 index 0000000..cca9720 --- /dev/null +++ b/tests/test_security_audit_mount_resolver.py @@ -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() diff --git a/tests/test_security_audit_wrapper.py b/tests/test_security_audit_wrapper.py index f4a5edb..e77cf02 100644 --- a/tests/test_security_audit_wrapper.py +++ b/tests/test_security_audit_wrapper.py @@ -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() diff --git a/tools/checks/security-audit/resolve_workspace_mount.py b/tools/checks/security-audit/resolve_workspace_mount.py new file mode 100644 index 0000000..7bf36c4 --- /dev/null +++ b/tools/checks/security-audit/resolve_workspace_mount.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import posixpath +from pathlib import Path +import re +import sys +from typing import Any + + +VOLUME_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") +UNSAFE_BIND_SOURCES = { + Path("/"), + Path("/bin"), + Path("/boot"), + Path("/dev"), + Path("/etc"), + Path("/home"), + Path("/lib"), + Path("/lib64"), + Path("/mnt"), + Path("/opt"), + Path("/proc"), + Path("/root"), + Path("/run"), + Path("/sbin"), + Path("/srv"), + Path("/sys"), + Path("/tmp"), + Path("/usr"), + Path("/var"), + Path("/var/run"), +} +SENSITIVE_BIND_ROOTS = { + Path("/dev"), + Path("/etc"), + Path("/proc"), + Path("/root"), + Path("/run"), + Path("/sys"), + Path("/var/run"), +} + + +class MountResolutionError(ValueError): + pass + + +def normalized_absolute_path( + value: str, + *, + field: str, + resolve_symlinks: bool = True, +) -> Path: + if not value.startswith("/") or value.startswith("//"): + raise MountResolutionError(f"{field} must be an absolute path") + path = Path(posixpath.normpath(value)) + return path.resolve(strict=False) if resolve_symlinks else path + + +def contains_path(container: Path, path: Path) -> bool: + return path == container or container in path.parents + + +def mount_option_value(value: str, *, field: str) -> str: + if not value or any(character in value for character in (",", "\n", "\r")): + raise MountResolutionError(f"{field} cannot be represented safely") + return value + + +def resolve_workspace_mount( + mounts: Any, + *, + root: str, + scope: str, + reports_dir: str, +) -> str: + if scope not in {"current", "govoplan"}: + raise MountResolutionError(f"unsupported audit scope: {scope}") + if not isinstance(mounts, list): + raise MountResolutionError("Docker mount metadata must be a list") + + root_path = normalized_absolute_path(root, field="audit root") + scan_root = root_path if scope == "current" else root_path.parent + reports_path = Path(reports_dir) + if not reports_path.is_absolute(): + reports_path = root_path / reports_path + reports_path = reports_path.resolve(strict=False) + + parsed_mounts: list[tuple[Path, dict[str, Any]]] = [] + for mount in mounts: + if not isinstance(mount, dict) or not isinstance(mount.get("Destination"), str): + continue + destination = normalized_absolute_path( + mount["Destination"], + field="mount destination", + ) + parsed_mounts.append((destination, mount)) + + candidates = [ + (destination, mount) + for destination, mount in parsed_mounts + if destination != Path("/") and contains_path(destination, scan_root) + ] + + if not candidates: + raise MountResolutionError( + f"no job-container mount covers audit scope root {scan_root}" + ) + longest_depth = max(len(destination.parts) for destination, _ in candidates) + selected = [ + candidate + for candidate in candidates + if len(candidate[0].parts) == longest_depth + ] + if len(selected) != 1: + raise MountResolutionError("job-container workspace mount is ambiguous") + + destination, mount = selected[0] + nested_mounts = [ + nested_destination + for nested_destination, _ in parsed_mounts + if nested_destination != destination + and contains_path(scan_root, nested_destination) + ] + if nested_mounts: + raise MountResolutionError( + "nested job-container mounts inside the audit scope cannot be " + "reproduced safely" + ) + if mount.get("RW") is not True: + raise MountResolutionError("job-container workspace mount is not writable") + if not contains_path(destination, reports_path): + raise MountResolutionError( + f"audit reports path {reports_path} is outside the workspace mount" + ) + + destination_value = mount_option_value( + str(destination), + field="mount destination", + ) + mount_type = mount.get("Type") + if mount_type == "volume": + name = mount.get("Name") + if not isinstance(name, str) or VOLUME_NAME.fullmatch(name) is None: + raise MountResolutionError("workspace volume name is missing or malformed") + source_value = name + elif mount_type == "bind": + source = mount.get("Source") + if not isinstance(source, str): + raise MountResolutionError("workspace bind source is missing") + source_path = normalized_absolute_path( + source, + field="mount source", + resolve_symlinks=False, + ) + if source_path in UNSAFE_BIND_SOURCES or any( + contains_path(sensitive_root, source_path) + for sensitive_root in SENSITIVE_BIND_ROOTS + ): + raise MountResolutionError( + "workspace bind source is too broad or sensitive" + ) + source_value = mount_option_value(str(source_path), field="mount source") + else: + raise MountResolutionError(f"unsupported workspace mount type: {mount_type!r}") + + return f"type={mount_type},source={source_value},target={destination_value}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Resolve one safe Gitea Actions workspace mount for an audit container." + ) + parser.add_argument("--root", required=True) + parser.add_argument("--scope", choices=("current", "govoplan"), required=True) + parser.add_argument("--reports-dir", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + mounts = json.load(sys.stdin) + print( + resolve_workspace_mount( + mounts, + root=args.root, + scope=args.scope, + reports_dir=args.reports_dir, + ) + ) + except (json.JSONDecodeError, MountResolutionError) as exc: + print(f"workspace mount error: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/checks/security-audit/run.sh b/tools/checks/security-audit/run.sh index 40d9ebd..47850cd 100755 --- a/tools/checks/security-audit/run.sh +++ b/tools/checks/security-audit/run.sh @@ -139,13 +139,53 @@ if [[ "$BUILD_ONLY" == "1" ]]; then exit 0 fi -if [[ "$SCOPE" == "govoplan" ]]; then +declare -a WORKSPACE_MOUNT=() +if [[ "${GITEA_ACTIONS:-}" == "true" ]]; then + ACTIONS_CONTAINER_ID="$(hostname)" + if ! ACTIONS_MOUNTS_JSON="$( + "${DOCKER_CLI[@]}" container inspect \ + --format '{{json .Mounts}}' \ + "$ACTIONS_CONTAINER_ID" + )"; then + echo "Gitea Actions is using a host Docker daemon, but the current job container could not be resolved." >&2 + echo "Cannot safely share the checked-out workspace with the audit container." >&2 + exit 1 + fi + if command -v python3 >/dev/null 2>&1; then + MOUNT_PYTHON=python3 + elif command -v python >/dev/null 2>&1; then + MOUNT_PYTHON=python + else + echo "Python is required to validate the Gitea Actions workspace mount." >&2 + exit 1 + fi + if ! ACTIONS_WORKSPACE_MOUNT="$( + printf '%s' "$ACTIONS_MOUNTS_JSON" \ + | "$MOUNT_PYTHON" "$ROOT/tools/checks/security-audit/resolve_workspace_mount.py" \ + --root "$ROOT" \ + --scope "$SCOPE" \ + --reports-dir "$REPORTS_DIR" + )"; then + echo "Cannot safely share the Gitea Actions workspace with the audit container." >&2 + exit 1 + fi + WORKSPACE_MOUNT=(--mount "$ACTIONS_WORKSPACE_MOUNT") + MOUNT_ROOT="$(dirname "$ROOT")" + WORKDIR="$ROOT" + if [[ "$SCOPE" == "govoplan" ]]; then + EXTRA_ENV=(-e "GOVOPLAN_REPOS_ROOT=$MOUNT_ROOT") + else + EXTRA_ENV=() + fi +elif [[ "$SCOPE" == "govoplan" ]]; then MOUNT_ROOT="$(dirname "$ROOT")" WORKDIR="/workspace/$(basename "$ROOT")" + WORKSPACE_MOUNT=(-v "$MOUNT_ROOT:/workspace") EXTRA_ENV=(-e "GOVOPLAN_REPOS_ROOT=/workspace") else MOUNT_ROOT="$ROOT" WORKDIR="/workspace" + WORKSPACE_MOUNT=(-v "$MOUNT_ROOT:/workspace") EXTRA_ENV=() fi @@ -159,7 +199,7 @@ fi -e SECURITY_AUDIT_REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" \ -e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \ "${EXTRA_ENV[@]}" \ - -v "$MOUNT_ROOT:/workspace" \ + "${WORKSPACE_MOUNT[@]}" \ -w "$WORKDIR" \ "$FINGERPRINT_IMAGE" \ bash tools/checks/check-security-audit.sh --mode "$MODE" --scope "$SCOPE" --reports-dir "$REPORTS_DIR" "${SCRIPT_ARGS[@]}"