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
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.

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

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
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[@]}"