diff --git a/.github/workflows/build-iso.yml b/.github/workflows/build-iso.yml index 75a5f4f..a6cc1e9 100644 --- a/.github/workflows/build-iso.yml +++ b/.github/workflows/build-iso.yml @@ -1,3 +1,6 @@ +# Named build-iso.yml for history. This workflow is source and package checks +# only; it does not build an ISO. The signed ISO is published from the +# authorized Linux publisher. See RELEASE-4.0.0.md. name: Source and package checks on: @@ -22,10 +25,42 @@ jobs: PYTHONDONTWRITEBYTECODE: '1' steps: - uses: actions/checkout@v4 + # Phoenix restore/report regression proofs compare against the scripts + # as published in v4.0.0 (`git show v4.0.0:...`). A shallow checkout + # has no tags, so those tests ERROR instead of measuring the pre-fix + # behaviour. + - name: Fetch v4.0.0 for phoenix regression proofs + run: git fetch --depth=1 origin tag v4.0.0 - name: Install source and package test dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential debhelper dh-python devscripts fakeroot python3 python3-yaml python3-pyqt6 python3-dbus python3-psutil shellcheck desktop-file-utils bubblewrap ffmpeg podman + sudo apt-get install -y build-essential debhelper dh-python devscripts fakeroot python3 python3-yaml python3-pyqt6 python3-dbus python3-psutil shellcheck desktop-file-utils bubblewrap ffmpeg podman dbus-user-session qemu-utils qemu-system-x86 slirp4netns + # ubuntu-24.04 sets kernel.apparmor_restrict_unprivileged_userns=1. + # bubblewrap can still create a user+net namespace, but configuring + # loopback inside it fails with RTM_NEWADDR EPERM, so every empirical + # sandbox test dies before the probe runs. Runners are disposable; + # lift the restriction for this job instead of skipping the suite. + - name: Allow bubblewrap network namespaces + run: | + if [ "$(sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0)" = "1" ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + bwrap --ro-bind / / --dev /dev --unshare-user --unshare-net --die-with-parent -- /bin/true + # Firebreak wraps every live sandbox in systemd-run --user. Hosted + # runners have systemd as PID 1 but no lingering user session, so the + # user bus is missing unless we start one. + - name: Start systemd user session for Firebreak + run: | + sudo loginctl enable-linger "$USER" + sudo systemctl start "user@$(id -u).service" + echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV" + echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV" + for _ in $(seq 1 40); do + [ -S "/run/user/$(id -u)/bus" ] && break + sleep 0.25 + done + test -S "/run/user/$(id -u)/bus" + systemd-run --user --scope --quiet --collect -- /bin/true - name: Test behavior run: make test - name: Validate source syntax and desktop entries @@ -44,7 +79,8 @@ jobs: first = text.splitlines()[0] if text.splitlines() else '' if p.suffix == '.py' or first.startswith('#!') and 'python' in first: ast.parse(text, filename=str(p)) - if p.suffix == '.json': + # Test fixtures may be deliberately invalid (e.g. broken-not-json.json). + if p.suffix == '.json' and 'tests' not in p.parts: json.loads(text) if p.suffix == '.desktop' and ('applications' in p.parts or 'autostart' in p.parts): subprocess.run(['desktop-file-validate', str(p)], check=True) diff --git a/packages/shadowfetch-fireline/tests/test_firebreak.sh b/packages/shadowfetch-fireline/tests/test_firebreak.sh index f518635..b7444c7 100755 --- a/packages/shadowfetch-fireline/tests/test_firebreak.sh +++ b/packages/shadowfetch-fireline/tests/test_firebreak.sh @@ -3,6 +3,14 @@ set -euo pipefail FB=${SHADOWFETCH_FIREBREAK_TEST_BIN:-shadowfetch-firebreak} CP=${SHADOWFETCH_CHECKPOINT_BIN:-shadowfetch-checkpoint} +# Firebreak launches via systemd-run --user. A socket at /run/user/$UID/bus +# is not enough -- a leftover dbus-daemon without a user manager still fails +# the launch. Probe the real wrapper. Missing namespaces remain a failure +# once systemd-run --user works (the Python live-sandbox tests skip too). +if ! systemd-run --user --scope --quiet --collect -- /bin/true >/dev/null 2>&1; then + echo "SKIP live Firebreak containment: systemd-run --user is not available" + exit 0 +fi fixture=$(mktemp -d) trap 'rm -rf "$fixture"' EXIT export SHADOWFETCH_AGENT_WORKSPACES="$fixture/Workspaces" diff --git a/packages/shadowfetch-fireproof/tests/test_single_update_authority.py b/packages/shadowfetch-fireproof/tests/test_single_update_authority.py index 7100e77..0732ed9 100644 --- a/packages/shadowfetch-fireproof/tests/test_single_update_authority.py +++ b/packages/shadowfetch-fireproof/tests/test_single_update_authority.py @@ -86,11 +86,17 @@ def code_only(text): def product_files(): - """Every shipped payload file under packages/, build copies excluded.""" + """Every shipped payload file under packages/, build copies excluded. + + Skip names are matched against the path relative to packages/, not the + absolute path. GitHub Actions checks out under /home/runner/work/, + and treating that ancestor `work` as a skip directory emptied the scan + so the pin lists compared against nothing. + """ for path in PACKAGES.rglob("*"): if not path.is_file(): continue - if any(part in SKIP_PARTS for part in path.parts): + if any(part in SKIP_PARTS for part in path.relative_to(PACKAGES).parts): continue yield path @@ -221,6 +227,16 @@ def product_code(): continue +class TestProductScanIsRelative(unittest.TestCase): + def test_an_ancestor_named_work_does_not_empty_the_scan(self): + """GitHub Actions checks out under /home/runner/work//.""" + files = list(product_files()) + self.assertTrue(files, "product_files() scanned an empty tree") + self.assertTrue( + any(path.name == "85fireproof" for path in files), + "the one APT::Periodic file was skipped with the rest") + + class TestOnlyOneUpdater(unittest.TestCase): def test_the_shim_contains_no_update_mechanism_of_its_own(self): body = code_only(SHIM.read_text()) diff --git a/packages/shadowfetch-missions/tests/test_missions.py b/packages/shadowfetch-missions/tests/test_missions.py index 6aeea9b..fdeb43f 100644 --- a/packages/shadowfetch-missions/tests/test_missions.py +++ b/packages/shadowfetch-missions/tests/test_missions.py @@ -347,7 +347,7 @@ def cli(command, label, **kwargs): def test_codex_incomplete_turn_and_missing_key_refuse_success(self): executor = m.Executor(self.store, self.create()) - with patch.dict(os.environ, {"CODEX_API_KEY": "", "OPENAI_API_KEY": ""}), patch.object(executor, "run_process", side_effect=AssertionError("No call without API key")): + with patch.dict(os.environ, {"CODEX_API_KEY": "", "OPENAI_API_KEY": ""}), patch.object(codex_adapter, "resolve_executable", return_value="/usr/bin/true"), patch.object(sf_providers, "declared_executables", return_value={"/usr/bin/true"}), patch.object(executor, "run_process", side_effect=AssertionError("No call without API key")): with self.assertRaisesRegex(m.MissionError, "not configured"): executor.agent_turn("task") log = executor.directory / "failed.jsonl" diff --git a/tools/attacks/attack_concurrency.py b/tools/attacks/attack_concurrency.py index ccaa730..29f8c1e 100755 --- a/tools/attacks/attack_concurrency.py +++ b/tools/attacks/attack_concurrency.py @@ -1309,6 +1309,18 @@ def accept(): egress = (record or {}).get("enforcement", {}).get("egress_allowlist", {}) network = (record or {}).get("enforcement", {}).get("network", {}) + if not reached: + report(name, + "an egress allowlist naming one host does not let the sandbox reach " + "the host's loopback, and any destination it CAN still reach is " + "described honestly rather than as enforced", + f"$ shadowfetch-firebreak run --net allow --egress-host one.one.one.one " + f"-- python3 (rc={proc.returncode}, no RESULT line)\n" + f"stderr={(proc.stderr or '')[-400:]}", + None, + "SKIPPED: Firebreak did not execute the probe. This measurement " + "needs systemd-run --user and slirp4netns.") + return loopback_contained = (reached.get("loopback") != "REACHED" and not received) # The allowlist named one.one.one.one and nothing else. A destination it # never named must not be reachable, and the one it did name must be: a @@ -1418,7 +1430,14 @@ def main(): def report(name, expected, observed, passed, note=""): rows.append({"attack": name, "expected": expected, "observed": observed, - "passed": bool(passed), "note": note}) + "passed": passed, "note": note}) + + def label(passed): + if passed is True: + return "PASS" + if passed is None: + return "SKIP" + return "FAIL" run(report) width = max(len(row["attack"]) for row in rows) @@ -1427,19 +1446,21 @@ def report(name, expected, observed, passed, note=""): print("=" * 78) for row in rows: print() - print(f"{'PASS' if row['passed'] else 'FAIL'} {row['attack']}") + print(f"{label(row['passed'])} {row['attack']}") print(f" EXPECTED {row['expected']}") for index, line in enumerate(row["observed"].splitlines() or [""]): print(f" {'OBSERVED ' if index == 0 else ' '}{line}") if row["note"]: print(f" NOTE {row['note']}") - failed = [row["attack"] for row in rows if not row["passed"]] + failed = [row["attack"] for row in rows if row["passed"] is False] print() print("-" * 78) for row in rows: - print(f" {'PASS' if row['passed'] else 'FAIL'} {row['attack']:<{width}}") + print(f" {label(row['passed'])} {row['attack']:<{width}}") print("-" * 78) - print(f"{len(rows) - len(failed)} passed, {len(failed)} FAILED") + skipped = sum(1 for row in rows if row["passed"] is None) + print(f"{len(rows) - len(failed) - skipped} passed, {len(failed)} FAILED" + + (f", {skipped} SKIPPED" if skipped else "")) if failed: print("FAILED: " + ", ".join(failed)) return 1 if failed else 0 diff --git a/tools/attacks/attack_domain.py b/tools/attacks/attack_domain.py index f5fa4bf..1336d25 100755 --- a/tools/attacks/attack_domain.py +++ b/tools/attacks/attack_domain.py @@ -8,7 +8,7 @@ """ import json, os, sqlite3, sys, tempfile, time from pathlib import Path -ENGINE = Path.home() / "projects/shadowfetch-4.0.0/packages/shadowfetch-missions/data/usr/lib/shadowfetch/missions" +ENGINE = Path(__file__).resolve().parents[2] / "packages/shadowfetch-missions/data/usr/lib/shadowfetch/missions" sys.path.insert(0, str(ENGINE)) R0 = tempfile.mkdtemp(prefix="stgA-") os.environ["SHADOWFETCH_MISSIONS_STATE"] = R0 diff --git a/tools/attacks/attack_lifecycle.py b/tools/attacks/attack_lifecycle.py index ab9d7cd..af427b2 100755 --- a/tools/attacks/attack_lifecycle.py +++ b/tools/attacks/attack_lifecycle.py @@ -277,7 +277,23 @@ def chain_summary(store): def missing_sandbox_tools(): - return [name for name in SANDBOX_TOOLS if shutil.which(name) is None] + missing = [name for name in SANDBOX_TOOLS if shutil.which(name) is None] + if missing: + return missing + # systemd-run is on PATH on this host, but Firebreak launches via + # systemd-run --user. A leftover dbus socket without a user manager + # still fails that wrapper; treat it as missing so these attacks skip + # instead of reporting a harness error as a product FAIL. + try: + done = subprocess.run( + ["systemd-run", "--user", "--scope", "--quiet", "--collect", + "--", "/bin/true"], + capture_output=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return ["systemd-run --user"] + if done.returncode != 0: + return ["systemd-run --user"] + return [] def lines(*parts): diff --git a/tools/drift_gate.py b/tools/drift_gate.py index 5c9d8a5..d67a413 100644 --- a/tools/drift_gate.py +++ b/tools/drift_gate.py @@ -364,6 +364,11 @@ def check_fingerprint(truth: dict) -> list[Finding]: if field is not None: if not _KEY_CONTEXT.search(field.group(1)): continue # e.g. "source_commit", "release_build", "URL" + elif re.search(r"(?i)\bcommit\b", line) and not _KEY_CONTEXT.search(line): + # Prose like "Source commit (ISO): " next to an OpenPGP + # fingerprint line: the 3-line window would otherwise inherit + # the word "fingerprint" and treat a git SHA as a signing key. + continue else: window = "\n".join(lines[max(0, number - 3):number + 1]) if not _KEY_CONTEXT.search(window): diff --git a/tools/tests/test_drift_gate.py b/tools/tests/test_drift_gate.py index d5d9ac5..2b97fc6 100644 --- a/tools/tests/test_drift_gate.py +++ b/tools/tests/test_drift_gate.py @@ -260,6 +260,23 @@ def test_a_commit_sha_is_not_mistaken_for_a_key(self): r'"source_commit":\s*"[0-9a-f]{40}"') self.assertEqual([], drifts(drift_gate.check_fingerprint(TRUTH))) + def test_a_prose_commit_sha_beside_a_fingerprint_is_not_a_key(self): + """sf41-ia-readme.txt names the signing key, then two git SHAs. + + The SHAs sit inside the three-line fingerprint window, so a sweep that + only looks at nearby labels would treat them as a second key.""" + with sandbox(*FINGERPRINT_RELS) as fake: + planted = fake / "sf41-ia-readme.txt" + planted.write_text( + "OpenPGP fingerprint: {fp}\n" + "Source commit (ISO): {sha1}\n" + "Release-tooling commit: {sha2}\n".format( + fp=TRUTH["signing"]["fingerprint"], + sha1="78ee38ceac0ff989d596e5a5e0b97aac17c3b936", + sha2="aa8fd1b22e8e3c8a098a28e43fd6b156fbb74b56"), + encoding="utf-8") + self.assertEqual([], drifts(drift_gate.check_fingerprint(TRUTH))) + def test_a_third_party_key_must_be_named(self): with sandbox(*FINGERPRINT_RELS) as fake: planted = fake / "packages/anything/vendor/provenance.json"