diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 8d50f62..1317550 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -47,7 +47,8 @@ jobs: tools/wolfglass-sync \ tests/test_gen_sbom.py \ tests/test_sbom.py \ - tests/test_sbom_identity.py + tests/test_sbom_identity.py \ + tests/test_wolfglass_sync.py - name: Run generator unit tests run: python -m unittest tests/test_gen_sbom.py @@ -61,5 +62,8 @@ jobs: - name: Run SBOM identity tests run: python -m unittest tests/test_sbom_identity.py + - name: Run wolfglass-sync tests + run: python -m unittest tests/test_wolfglass_sync.py + - name: Run self-test run: python tests/test_sbom.py diff --git a/tests/test_wolfglass_sync.py b/tests/test_wolfglass_sync.py new file mode 100644 index 0000000..0bd8370 --- /dev/null +++ b/tests/test_wolfglass_sync.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Tests for tools/wolfglass-sync, focused on the --subdir containment guard. + +--subdir is joined onto --dest and then written into, so an absolute value or +one containing .. must be refused rather than allowed to place/overwrite files +outside the product's vendoring path.""" + +import os +import subprocess +import sys +import tempfile +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SYNC = os.path.join(REPO, "tools", "wolfglass-sync") + + +def _run(dest, subdir): + return subprocess.run( + [sys.executable, SYNC, "--dest", dest, "--subdir", subdir], + capture_output=True, text=True) + + +class TestSubdirContainment(unittest.TestCase): + def test_relative_subdir_copies_inside_dest(self): + with tempfile.TemporaryDirectory() as dest: + r = _run(dest, "tools/sbom") + self.assertEqual(r.returncode, 0, r.stderr) + # gen-sbom is part of share/, so it must land under the subdir. + self.assertTrue( + os.path.isfile(os.path.join(dest, "tools", "sbom", "gen-sbom")), + os.listdir(dest)) + + def test_absolute_subdir_rejected(self): + with tempfile.TemporaryDirectory() as dest, \ + tempfile.TemporaryDirectory() as outside: + target = os.path.join(outside, "pwned") + r = _run(dest, target) # absolute path + self.assertNotEqual(r.returncode, 0) + self.assertIn("--subdir", r.stderr) + self.assertFalse(os.path.exists(target), + "absolute --subdir wrote outside --dest") + + def test_dotdot_subdir_rejected(self): + with tempfile.TemporaryDirectory() as parent: + dest = os.path.join(parent, "product") + os.mkdir(dest) + r = _run(dest, "../escape") + self.assertNotEqual(r.returncode, 0) + self.assertIn("escapes --dest", r.stderr) + self.assertFalse(os.path.exists(os.path.join(parent, "escape")), + "../ --subdir wrote outside --dest") + + def test_subdir_symlink_outside_rejected(self): + with tempfile.TemporaryDirectory() as dest, \ + tempfile.TemporaryDirectory() as outside: + os.symlink(outside, os.path.join(dest, "link")) + r = _run(dest, "link") + self.assertNotEqual(r.returncode, 0) + self.assertIn("escapes --dest", r.stderr) + self.assertEqual(os.listdir(outside), [], + "symlinked --subdir wrote outside --dest") + + def test_root_dest_accepts_nested_subdir(self): + with tempfile.TemporaryDirectory() as tmp: + sub = os.path.relpath(os.path.join(tmp, "a", "b"), os.sep) + r = _run(os.sep, sub) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue( + os.path.isfile(os.path.join(tmp, "a", "b", "gen-sbom"))) + + +class TestSymlinkInsideDest(unittest.TestCase): + """A symlink already present under --subdir must not redirect writes.""" + + def test_file_symlink_escape_refused(self): + with tempfile.TemporaryDirectory() as dest, \ + tempfile.TemporaryDirectory() as outside: + victim = os.path.join(outside, "victim") + with open(victim, "w") as f: + f.write("original\n") + sbom = os.path.join(dest, "tools", "sbom") + os.makedirs(sbom) + os.symlink(victim, os.path.join(sbom, "gen-sbom")) + r = _run(dest, "tools/sbom") + self.assertNotEqual(r.returncode, 0) + with open(victim) as f: + self.assertEqual(f.read(), "original\n") + self.assertEqual(os.listdir(outside), ["victim"]) + + def test_dir_symlink_escape_refused(self): + with tempfile.TemporaryDirectory() as dest, \ + tempfile.TemporaryDirectory() as outside: + sbom = os.path.join(dest, "tools", "sbom") + os.makedirs(sbom) + os.symlink(outside, os.path.join(sbom, "frontends")) + r = _run(dest, "tools/sbom") + self.assertNotEqual(r.returncode, 0) + self.assertEqual(os.listdir(outside), [], + "symlinked dir under --subdir wrote outside") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tools/wolfglass-sync b/tools/wolfglass-sync index f0439df..fb94c1b 100755 --- a/tools/wolfglass-sync +++ b/tools/wolfglass-sync @@ -39,6 +39,11 @@ def iter_share_files(share_dir): yield full, os.path.relpath(full, share_dir) +def is_inside(base, path): + """True if absolute path equals base or lies beneath it.""" + return os.path.commonpath([base, path]) == base + + def source_revision(src_root): """Return the source git revision if available, else an empty string.""" try: @@ -66,7 +71,19 @@ def main(): if not os.path.isdir(share_dir): sys.exit(f"ERROR: no share/ directory at {share_dir}") + # --subdir is joined onto --dest and then written into. An absolute value + # (os.path.join discards --dest) or one containing .. would place and + # overwrite files outside the product's vendoring path, so require it to + # resolve to a location inside --dest. + if os.path.isabs(args.subdir): + sys.exit(f"ERROR: --subdir must be relative to --dest, got absolute " + f"{args.subdir!r}.") dest_root = os.path.join(args.dest, args.subdir) + dest_abs = os.path.realpath(args.dest) + dest_root_abs = os.path.realpath(dest_root) + if not is_inside(dest_abs, dest_root_abs): + sys.exit(f"ERROR: --subdir {args.subdir!r} escapes --dest; it must " + f"resolve to a path inside {args.dest!r}.") files = sorted(iter_share_files(share_dir), key=lambda p: p[1]) if args.check: @@ -98,6 +115,20 @@ def main(): print("OK: vendored wolfGlass files match share/.") return + # A symlink already present under dest_root (a file, or a directory on + # the way to one) would redirect a write outside it. Resolve every target + # before writing anything and refuse if one leaves dest_root. + targets = [rel for _, rel in files] + if os.path.isfile(version_file): + targets.append("VERSION") + if source_rev: + targets.append(".wolfglass-rev") + for rel in targets: + if not is_inside(dest_root_abs, + os.path.realpath(os.path.join(dest_root, rel))): + sys.exit(f"ERROR: {rel!r} under {dest_root!r} resolves outside " + f"it through a symlink; refusing to write.") + count = 0 for src, rel in files: dst = os.path.join(dest_root, rel)