Skip to content
Merged
40 changes: 38 additions & 2 deletions .github/workflows/build-iso.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions packages/shadowfetch-fireline/tests/test_firebreak.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>,
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

Expand Down Expand Up @@ -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/<repo>/<repo>."""
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())
Expand Down
2 changes: 1 addition & 1 deletion packages/shadowfetch-missions/tests/test_missions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 26 additions & 5 deletions tools/attacks/attack_concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tools/attacks/attack_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion tools/attacks/attack_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions tools/drift_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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): <sha>" 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):
Expand Down
17 changes: 17 additions & 0 deletions tools/tests/test_drift_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading