ci: mount audit workspace safely
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m43s
Security Audit / security-audit (push) Failing after 4m10s

This commit is contained in:
2026-07-23 00:30:28 +02:00
parent 3fc17701df
commit 8b93bbc6b6
5 changed files with 656 additions and 6 deletions

View File

@@ -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 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, self-hosted Gitea runner using the host Docker socket. Trusted push, schedule,
and manual runs scan all registered repositories; authenticated SSH is used and manual runs scan all registered repositories; authenticated SSH is used
only for the private website repository. Pull-request audit runs stay disabled only for the private website repository. The wrapper inspects the Actions job
while the audit runner exposes its host Docker socket: PR-controlled audit code mount table and forwards only the narrowest writable mount covering the audit
must run on a disposable or rootless runner without host-socket access. The scope; it never inherits the job's Docker socket or unrelated runner mounts.
separate 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 Toolbox Update` workflow runs weekly with
`SECURITY_AUDIT_UPDATE=1`; it pulls current base images and re-resolves the `SECURITY_AUDIT_UPDATE=1`; it pulls current base images and re-resolves the
allowed tool version ranges into a refreshed local image. allowed tool version ranges into a refreshed local image.

View 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()

View File

@@ -12,6 +12,7 @@ import unittest
META_ROOT = Path(__file__).resolve().parents[1] META_ROOT = Path(__file__).resolve().parents[1]
AUDIT_SCRIPT = META_ROOT / "tools" / "checks" / "check-security-audit.sh" AUDIT_SCRIPT = META_ROOT / "tools" / "checks" / "check-security-audit.sh"
CONTAINER_RUNNER = META_ROOT / "tools" / "checks" / "security-audit" / "run.sh"
class SecurityAuditWrapperTests(unittest.TestCase): class SecurityAuditWrapperTests(unittest.TestCase):
@@ -354,5 +355,162 @@ class SecurityAuditWrapperTests(unittest.TestCase):
self.assertEqual(set(manifest["reports"]), checksummed_paths) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -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())

View File

@@ -139,13 +139,53 @@ if [[ "$BUILD_ONLY" == "1" ]]; then
exit 0 exit 0
fi 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")" MOUNT_ROOT="$(dirname "$ROOT")"
WORKDIR="/workspace/$(basename "$ROOT")" WORKDIR="/workspace/$(basename "$ROOT")"
WORKSPACE_MOUNT=(-v "$MOUNT_ROOT:/workspace")
EXTRA_ENV=(-e "GOVOPLAN_REPOS_ROOT=/workspace") EXTRA_ENV=(-e "GOVOPLAN_REPOS_ROOT=/workspace")
else else
MOUNT_ROOT="$ROOT" MOUNT_ROOT="$ROOT"
WORKDIR="/workspace" WORKDIR="/workspace"
WORKSPACE_MOUNT=(-v "$MOUNT_ROOT:/workspace")
EXTRA_ENV=() EXTRA_ENV=()
fi fi
@@ -159,7 +199,7 @@ fi
-e SECURITY_AUDIT_REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" \ -e SECURITY_AUDIT_REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" \
-e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \ -e SECURITY_AUDIT_TOOLBOX_FINGERPRINT="$IMAGE_FINGERPRINT" \
"${EXTRA_ENV[@]}" \ "${EXTRA_ENV[@]}" \
-v "$MOUNT_ROOT:/workspace" \ "${WORKSPACE_MOUNT[@]}" \
-w "$WORKDIR" \ -w "$WORKDIR" \
"$FINGERPRINT_IMAGE" \ "$FINGERPRINT_IMAGE" \
bash tools/checks/check-security-audit.sh --mode "$MODE" --scope "$SCOPE" --reports-dir "$REPORTS_DIR" "${SCRIPT_ARGS[@]}" bash tools/checks/check-security-audit.sh --mode "$MODE" --scope "$SCOPE" --reports-dir "$REPORTS_DIR" "${SCRIPT_ARGS[@]}"