Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Proves the host's git calls cannot be turned into code execution.
python3 tests/test_vm_git_hardening.py
WHY THIS TEST EXISTS
The project share is mounted inside the VM, so anything the guest runs
can write `.git/` - and git is designed to execute things it finds there.
vmd commits that tree on the host. Without care, "the agent finished and
vmd committed the result" means "the guest chose a program and vmd ran
it".
Every case here plants a real attack and then asserts it did not fire.
The first case is a CONTROL: it uses plain git and the attack MUST
succeed. Without that, this file would pass just as happily if git had
stopped running hooks altogether, or if the planted hook were never
executable, and it would be proving nothing at all.
"""
import os
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "vm", "vmd"))
import gitea # noqa: E402
FAILS: list[str] = []
def check(label: str, condition: bool, detail: str = "") -> None:
if condition:
print(f"ok {label}")
else:
FAILS.append(f"{label}: {detail}")
print(f"FAIL {label}\n {detail}")
def make_repo(tmp: str, marker: str) -> tuple[str, str]:
"""A repo booby-trapped the way a hostile guest would leave it."""
repo = os.path.join(tmp, "project")
os.makedirs(os.path.join(repo, "evil"), exist_ok=True)
subprocess.run(["git", "init", "-q", "-b", "main", repo], check=True)
with open(os.path.join(repo, "file.txt"), "w") as handle:
handle.write("content\n")
hooks = os.path.join(repo, ".git", "hooks")
os.makedirs(hooks, exist_ok=True)
for path in (os.path.join(hooks, "pre-commit"), os.path.join(repo, "evil", "pre-commit")):
with open(path, "w") as handle:
handle.write(f"#!/bin/sh\necho fired >> {marker}\n")
os.chmod(path, 0o755)
# The second half of the attack: even with .git/hooks neutralised,
# repository config can point hooksPath somewhere else entirely.
subprocess.run(["git", "-C", repo, "config", "core.hooksPath",
os.path.join(repo, "evil")], check=True)
return repo, marker
with tempfile.TemporaryDirectory() as tmp:
marker = os.path.join(tmp, "fired")
# --- CONTROL: the attack must work against plain git ---------------
repo, _ = make_repo(tmp, marker)
subprocess.run(["git", "-C", repo, "add", "-A"], check=True,
capture_output=True)
subprocess.run(["git", "-C", repo, "-c", "user.name=t", "-c", "user.email=t@t",
"commit", "-qm", "control"], capture_output=True)
check("CONTROL: plain git runs the planted hook",
os.path.exists(marker),
"the hook did not fire, so this file proves nothing - fix the control first")
# --- the real case -------------------------------------------------
if os.path.exists(marker):
os.unlink(marker)
with open(os.path.join(repo, "file.txt"), "a") as handle:
handle.write("more\n")
sha = gitea.commit(repo, "hardened commit")
check("gitea.commit still commits", bool(sha), f"returned {sha!r}")
check("no hook fired under gitea.commit",
not os.path.exists(marker),
"a planted hook executed on the host during a commit")
# --- and the flags that make that true are actually present --------
flags = " ".join(gitea._SAFE_FLAGS)
for needed in ("core.hooksPath=/var/empty", "core.fsmonitor=false",
"protocol.ext.allow=never", "protocol.file.allow=never"):
check(f"flag present: {needed}", needed in flags, flags)
for needed in ("GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"):
check(f"env neutralised: {needed}", gitea._SAFE_ENV.get(needed) == "/dev/null",
str(gitea._SAFE_ENV))
# --- a token must never reach the tree -----------------------------
config_path = os.path.join(repo, ".git", "config")
config = open(config_path).read() if os.path.exists(config_path) else ""
check("no credential was written into .git/config",
"@" not in config.split("url =")[-1][:200] if "url =" in config else True,
config[:200])
# --- what the person is handed at the end --------------------------------
#
# Two complaints from a real session, both about the last thing the user
# sees. They were handed the repository URL when they had asked for a link
# to download the script - a page to navigate rather than a file to save.
# And the repository was labelled "Created by the owuitools VM agent for
# project 'systeminfo-ps'", which says nothing about what the project is.
work = tempfile.mkdtemp()
def run_git(*args):
subprocess.run(["git", "-C", work, *args], check=True, capture_output=True,
env={**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null"})
run_git("init", "-q", "-b", "main")
for name in ("SystemInfo.ps1", "systeminfo.json", ".gitignore"):
open(os.path.join(work, name), "w").write("x")
os.makedirs(os.path.join(work, "sub"), exist_ok=True)
open(os.path.join(work, "sub", "deep.ps1"), "w").write("x")
open(os.path.join(work, "left-behind.txt"), "w").write("x")
run_git("add", "SystemInfo.ps1", "systeminfo.json", ".gitignore", "sub/deep.ps1")
run_git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x")
tracked = gitea.tracked_files(work)
check("a download link is only offered for a file that was committed",
"SystemInfo.ps1" in tracked and "left-behind.txt" not in tracked,
"a link to an uncommitted file is a 404 with the operator's name on it")
check("and not for the repository's own dotfiles", ".gitignore" not in tracked)
check("the file asked for comes before anything nested",
tracked.index("SystemInfo.ps1") < tracked.index("sub/deep.ps1"))
# The link handed to the user is the releases page, because a link into
# the repository does NOT download: measured, Gitea serves the raw file as
# `text/plain` with `Content-Disposition: inline`, so the browser shows the
# source. Release assets are attachments.
check("a release is built from what was committed, and is capped",
gitea.RELEASE_ASSETS_MAX > 0 and gitea.RELEASE_ASSET_MAX > 0)
check("the asset list is the committed files",
"tracked_files(repo_path" in open(
os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "vm", "vmd", "gitea.py"), encoding="utf-8").read(),
"an asset for an uncommitted file is a download of something that was thrown away")
gitea_src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "vm", "vmd", "gitea.py"), encoding="utf-8").read()
check("a nested file keeps its path in the asset name",
"name.replace('/', '_')" in gitea_src,
"a release has no folders, so two files called the same thing would collide")
vmd_src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "vm", "vmd", "vmd.py"), encoding="utf-8").read()
publish = vmd_src[vmd_src.index("def _publish"):vmd_src.index("def v_session_status")]
check("the release is published after the push, not instead of it",
publish.index("gitea.push(") < publish.index("gitea.publish_release("),
"the tag has to point at a commit the server already has")
check("a release that fails does not fail the session",
"release_error" in publish and "publish_error" in publish,
"the work is committed and pushed by then; only the download page is missing")
check("uploading an asset is multipart, as Gitea requires",
"multipart/form-data; boundary=" in gitea_src)
check("the upload boundary is not guessable from the file",
"os.urandom" in gitea_src,
"a fixed boundary appearing inside a file would corrupt the upload")
# The description is generated from the job, so it cannot become a pitch
# and cannot describe work that was never asked for.
desc = gitea.short_description(
"Goal: Write a PowerShell script that collects system information and prints it. "
"Then test both modes and report the results verbatim.", "systeminfo-ps")
check("the repository says what the project is", "PowerShell script" in desc, desc)
check("it is one line and short",
"\n" not in desc and len(desc) <= gitea.SUMMARY_CAP + 3, f"{len(desc)}: {desc}")
check("it stops at the first sentence rather than reciting the job",
"test both modes" not in desc, desc)
check("a label like 'Goal:' is not part of the description",
not desc.lower().startswith("goal"), desc)
check("a job with nothing usable still gets a description",
gitea.short_description("", "demo") != "")
# A job opens by addressing the machine and telling it to build something.
# A repository description labels the thing that was built, so neither the
# machine nor the instruction belongs in it. Measured on a real session,
# the first version read "On the Ubuntu machine, write a small Python
# script called wordcount.py that takes a filename and prints the..." -
# the instruction, verbatim and cut off.
built = gitea.short_description(
"On the Ubuntu machine, write a small Python script called wordcount.py that takes "
"a filename and prints the ten most common words with their counts. Save it in the "
"project folder and run it.", "wordcount-demo")
check("the machine is not part of the description",
"ubuntu" not in built.lower() and not built.lower().startswith("on the"), built)
check("nor is the instruction that asked for it",
not built.lower().startswith("write"), built)
check("what is left describes the thing that was built",
built.startswith("Small Python script called wordcount.py"), built)
# Only verbs that CREATE are stripped. A project whose job was to test
# something is accurately described by the word "test".
checked = gitea.short_description(
"Test the bash command find -mtime +7 -delete and report which files it removes.",
"find-test")
check("a job that was a test still says so", checked.lower().startswith("test"), checked)
print()
print("=" * 60)
if FAILS:
print(f"FAILED {len(FAILS)}")
for f in FAILS:
print(" -", f)
sys.exit(1)
print("all git hardening cases pass")