Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/stealth-active.sh
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ wait_http http://"$target":9001/
run_detached "$control" \
--user "$host_user" \
-v "$tmp:/run/stealth:ro" \
"$lab_image" cover-server --listen :8443 --cert /run/stealth/server.crt --key /run/stealth/server.key --origin http://"$origin":8080
"$lab_image" cover-server --listen :8443 --cert /run/stealth/server.crt --key /run/stealth/server.key --origin http://"$origin":8080 --alt-svc-on-h3
run_detached "$hy_control" \
--user "$host_user" \
-v "$tmp:/run/stealth:ro" \
Expand Down
12 changes: 10 additions & 2 deletions scripts/stealth-browser/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ ARG BASE_IMAGE
FROM ${BASE_IMAGE}

ARG BASE_IMAGE
ARG DEBIAN_SNAPSHOT
ARG DEBIAN_SECURITY_SNAPSHOT
ARG BROWSER_FAMILY
ARG BROWSER_PACKAGE_VERSION
ARG WEBDRIVER_PACKAGE_VERSION
Expand All @@ -18,8 +20,11 @@ LABEL org.opencontainers.image.title="AutoCAR real-browser workload runner" \
org.opencontainers.image.licenses="MIT" \
com.cppla.autocar.browser-family="${BROWSER_FAMILY}" \
com.cppla.autocar.browser-package-version="${BROWSER_PACKAGE_VERSION}" \
com.cppla.autocar.webdriver-package-version="${WEBDRIVER_PACKAGE_VERSION}"
com.cppla.autocar.webdriver-package-version="${WEBDRIVER_PACKAGE_VERSION}" \
com.cppla.autocar.debian-snapshot="${DEBIAN_SNAPSHOT}" \
com.cppla.autocar.debian-security-snapshot="${DEBIAN_SECURITY_SNAPSHOT}"

COPY scripts/stealth-browser/apt_sources.sh /campaign/apt_sources.sh
COPY scripts/stealth-browser/run_cover.py /campaign/run_cover.py
COPY scripts/stealth-browser/write_lock.py /campaign/write_lock.py

Expand All @@ -28,6 +33,9 @@ RUN set -eu; \
case "${BROWSER_FAMILY}" in chromium|firefox-esr) ;; *) echo 'BROWSER_FAMILY must be chromium or firefox-esr' >&2; exit 2 ;; esac; \
printf '%s' "${BROWSER_PACKAGE_VERSION}" | grep -Eq '^[A-Za-z0-9.+:~_-]+$' || { echo 'BROWSER_PACKAGE_VERSION must be exact' >&2; exit 2; }; \
printf '%s' "${WEBDRIVER_PACKAGE_VERSION}" | grep -Eq '^[A-Za-z0-9.+:~_-]+$' || { echo 'WEBDRIVER_PACKAGE_VERSION must be exact' >&2; exit 2; }; \
/bin/sh /campaign/apt_sources.sh "${DEBIAN_SNAPSHOT}" "${DEBIAN_SECURITY_SNAPSHOT}" > /tmp/frozen.sources; \
rm -f /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; \
mv /tmp/frozen.sources /etc/apt/sources.list.d/debian.sources; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates python3; \
if [ "${BROWSER_FAMILY}" = chromium ]; then \
Expand Down Expand Up @@ -78,7 +86,7 @@ RUN set -eu; \
--output /campaign/browser-lock.json >/dev/null; \
fi; \
apt-get clean; \
rm -rf /var/lib/apt/lists/* /campaign/write_lock.py
rm -rf /var/lib/apt/lists/* /campaign/write_lock.py /campaign/apt_sources.sh

ENV HOME=/tmp \
STEALTH_BROWSER_LOCK=/campaign/browser-lock.json \
Expand Down
28 changes: 26 additions & 2 deletions scripts/stealth-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ the receipt preserves the `browser_h2` label expected by the campaign driver.

## Reproducible images

Build one immutable image per browser. The reviewed Debian 13 amd64 inputs are
checked in as `versions.debian13-amd64.json`. Every build requires:
Build one immutable image per browser. The reviewed Debian 13 inputs are
checked in separately as `versions.debian13-amd64.json` and
`versions.debian13-arm64.json`. Package versions and APT snapshots match; the
Mozilla driver archive checksum is architecture-specific. Every build requires:

- `BASE_IMAGE`: a Debian 13 base reference ending in `@sha256:<64 hex>`;
- `DEBIAN_SNAPSHOT` and `DEBIAN_SECURITY_SNAPSHOT`: the exact UTC archive
timestamps from the manifest, in `YYYYMMDDTHHMMSSZ` format;
- `BROWSER_FAMILY`: `chromium` or `firefox-esr`;
- `BROWSER_PACKAGE_VERSION`: an exact Debian package version; and
- `WEBDRIVER_PACKAGE_VERSION`: the exact matching Debian chromium-driver
Expand All @@ -41,18 +45,38 @@ Firefox builds also require `WEBDRIVER_ARCHIVE_SHA256`, which is checked before
the Mozilla release archive is extracted. The Dockerfile supports `amd64` and
`arm64` Firefox release assets.

APT uses only the two frozen official Debian snapshot archives. This keeps the
exact browser packages available after rolling mirrors remove old versions.
Only archived metadata expiry is ignored, per source; Debian signature and
package checksum checks remain enabled. HTTP permits bootstrap before the slim
base has CA certificates; APT authenticates these archives using the base image's
Debian keyring. These historical browser images are for the isolated experiment,
not general browsing or production deployment. Changing archive pins changes
the experiment inputs and requires a new frozen campaign; never rewrite an old
campaign to use the new images.

The lab launcher selects native Linux amd64 or arm64 pins, refuses a different
Docker engine architecture, and fetches the matching official baseline binary.
An ARM pilot is a separate host-bound campaign, not additional samples for an
existing x86_64 campaign. Passing build and calibration checks does not establish
browser indistinguishability or cross-architecture equivalence.

Example shapes, with reviewed exact values substituted:

```sh
docker build -f scripts/stealth-browser/Dockerfile \
--build-arg 'BASE_IMAGE=debian:13-slim@sha256:<reviewed-base-digest>' \
--build-arg DEBIAN_SNAPSHOT=20260904T145907Z \
--build-arg DEBIAN_SECURITY_SNAPSHOT=20260905T202337Z \
--build-arg BROWSER_FAMILY=chromium \
--build-arg 'BROWSER_PACKAGE_VERSION=<exact-debian-version>' \
--build-arg 'WEBDRIVER_PACKAGE_VERSION=<same-exact-debian-version>' \
-t autocar-stealth-browser:chromium .

docker build -f scripts/stealth-browser/Dockerfile \
--build-arg 'BASE_IMAGE=debian:13-slim@sha256:<reviewed-base-digest>' \
--build-arg DEBIAN_SNAPSHOT=20260904T145907Z \
--build-arg DEBIAN_SECURITY_SNAPSHOT=20260905T202337Z \
--build-arg BROWSER_FAMILY=firefox-esr \
--build-arg 'BROWSER_PACKAGE_VERSION=<exact-debian-version>' \
--build-arg 'WEBDRIVER_PACKAGE_VERSION=<exact-geckodriver-version>' \
Expand Down
16 changes: 16 additions & 0 deletions scripts/stealth-browser/apt_sources.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu

# Emit only frozen, signature-checked Debian sources. No host files are edited.
[ "$#" -eq 2 ] || { echo 'usage: apt_sources.sh DEBIAN_TIMESTAMP SECURITY_TIMESTAMP' >&2; exit 2; }
for snapshot in "$@"; do
[ "${#snapshot}" -eq 16 ] && printf '%s' "$snapshot" | grep -Eq '^[0-9]{8}T[0-9]{6}Z$' || {
echo 'APT snapshot must be an exact YYYYMMDDTHHMMSSZ timestamp' >&2
exit 2
}
done

# HTTP bootstraps the CA-less base image. APT still verifies Debian signatures
# and package hashes using its pinned-image archive keyring. Only the archived
# metadata's expiry check is disabled, separately on each frozen source.
printf 'Types: deb\nURIs: http://snapshot.debian.org/archive/debian/%s/\nSuites: trixie trixie-updates\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\nCheck-Valid-Until: no\n\nTypes: deb\nURIs: http://snapshot.debian.org/archive/debian-security/%s/\nSuites: trixie-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\nCheck-Valid-Until: no\n' "$1" "$2"
171 changes: 160 additions & 11 deletions scripts/stealth-browser/test_run_cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
import json
from pathlib import Path
import runpy
import shlex
import stat
import subprocess
import sys
import tempfile
from types import SimpleNamespace
Expand Down Expand Up @@ -46,6 +48,144 @@ def seeded_fixture_body(size: int, seed: int, request_index: int, domain: int) -
return bytes(result)


class AptSourcesTest(unittest.TestCase):
debian_timestamp = "20260915T010203Z"
security_timestamp = "20261001T040506Z"

def sources(self, *args: str) -> subprocess.CompletedProcess[str]:
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run(
["sh", str(HERE / "apt_sources.sh"), *args],
cwd=directory,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=5,
check=False,
)
self.assertEqual(list(Path(directory).iterdir()), [], "helper wrote files")
return result

def test_snapshot_sources_preserve_separate_archives_and_signature_checks(self) -> None:
result = self.sources(self.debian_timestamp, self.security_timestamp)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
stanzas = result.stdout.strip().split("\n\n")
self.assertEqual(len(stanzas), 2)
for stanza, archive, timestamp, suites in zip(
stanzas,
("debian", "debian-security"),
(self.debian_timestamp, self.security_timestamp),
("trixie trixie-updates", "trixie-security"),
):
with self.subTest(archive=archive):
fields = {}
for line in stanza.splitlines():
key, separator, value = line.partition(":")
self.assertEqual(separator, ":", "expected a deb822 field")
self.assertNotIn(key, fields, "duplicate deb822 field")
fields[key] = value.strip()
self.assertIn(
fields.pop("URIs", None),
{
f"{scheme}://snapshot.debian.org/archive/{archive}/{timestamp}/"
for scheme in ("http", "https")
},
)
self.assertEqual(fields, {
"Types": "deb",
"Suites": suites,
"Components": "main",
"Signed-By": "/usr/share/keyrings/debian-archive-keyring.gpg",
"Check-Valid-Until": "no",
})

def test_snapshot_sources_reject_wrong_argument_count_without_output(self) -> None:
for args in (
(),
(self.debian_timestamp,),
(self.debian_timestamp, self.security_timestamp, self.security_timestamp),
):
with self.subTest(args=args):
result = self.sources(*args)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")

def test_snapshot_sources_reject_unfrozen_or_injected_values_without_output(self) -> None:
for invalid in (
"",
"latest",
"2026-10-01T04:05:06Z",
"20261001T040506",
"20261001t040506z",
"20261001T040506ZZ",
" 20261001T040506Z",
"20261001T040506Z ",
"20261001T040506Z\n",
"20261001T040506Z\r\n",
"20261001T040506Z\nURIs: https://example.invalid/",
"https://snapshot.debian.org/archive/debian/20261001T040506Z/",
"../20261001T040506Z",
"/archive/debian/20261001T040506Z",
"20261001T040506Z/../../etc/apt",
"20261001T040506Z; : > injected",
"$(printf injected)",
):
for index in (0, 1):
with self.subTest(invalid=invalid, argument=index):
args = [self.debian_timestamp, self.security_timestamp]
args[index] = invalid
result = self.sources(*args)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")

def test_checked_in_manifest_pins_both_apt_archives(self) -> None:
snapshots_by_architecture = {}
for architecture in ("amd64", "arm64"):
with self.subTest(architecture=architecture):
pins = json.loads((HERE / f"versions.debian13-{architecture}.json").read_text(encoding="utf-8"))
self.assertEqual(pins["architecture"], architecture)
self.assertIn("apt_snapshots", pins)
snapshots = pins["apt_snapshots"]
self.assertEqual(set(snapshots), {"debian", "debian_security"})
for archive, timestamp in snapshots.items():
with self.subTest(archive=archive):
self.assertIsInstance(timestamp, str)
self.assertRegex(timestamp, r"\A[0-9]{8}T[0-9]{6}Z\Z")
snapshots_by_architecture[architecture] = snapshots
self.assertEqual(snapshots_by_architecture.get("amd64"), snapshots_by_architecture.get("arm64"))

def test_launcher_passes_both_snapshots_to_each_browser_build(self) -> None:
launcher = (HERE.parent / "stealth-full-lab.sh").read_text(encoding="utf-8")
builds = []
for command in launcher.replace("\\\n", " ").splitlines():
if "scripts/stealth-browser/Dockerfile" not in command:
continue
words = shlex.split(command, comments=True)
if "build" not in words:
continue
arguments = {}
for index, word in enumerate(words):
if word == "--build-arg":
self.assertLess(index + 1, len(words))
argument = words[index + 1]
elif word.startswith("--build-arg="):
argument = word.removeprefix("--build-arg=")
else:
continue
name, separator, value = argument.partition("=")
self.assertEqual(separator, "=")
self.assertNotIn(name, arguments, "duplicate browser build argument")
arguments[name] = value
builds.append(arguments)
self.assertEqual(len(builds), 2)
self.assertCountEqual([build.get("BROWSER_FAMILY") for build in builds], ["chromium", "firefox-esr"])
for build in builds:
with self.subTest(browser=build["BROWSER_FAMILY"]):
self.assertIn(build.get("DEBIAN_SNAPSHOT"), ("$debian_snapshot", "${debian_snapshot}"))
self.assertIn(build.get("DEBIAN_SECURITY_SNAPSHOT"), ("$debian_security_snapshot", "${debian_security_snapshot}"))


class BrowserRunnerTest(unittest.TestCase):
def test_private_server_parser_is_strict(self) -> None:
self.assertEqual(run_cover.parse_server("10.2.3.4:8443").authority, "10.2.3.4:8443")
Expand Down Expand Up @@ -94,17 +234,26 @@ def test_frozen_workload_matrix_and_sizes(self) -> None:
run_cover.WORKLOAD_SPECS["download_128k"],
)

def test_checked_in_amd64_build_inputs_are_exact(self) -> None:
pins = json.loads((HERE / "versions.debian13-amd64.json").read_text(encoding="utf-8"))
self.assertRegex(pins["base_image"], r"@sha256:[0-9a-f]{64}$")
self.assertEqual(
pins["chromium"]["browser_package_version"],
pins["chromium"]["webdriver_package_version"],
)
self.assertRegex(
pins["firefox-esr"]["webdriver_archive_sha256"], r"^[0-9a-f]{64}$"
)
self.assertEqual(pins["firefox-esr"]["webdriver_package_version"], "0.36.0")
def test_checked_in_build_inputs_are_exact_for_both_architectures(self) -> None:
gecko_hashes = {
"amd64": "0bde38707eb0a686a20c6bd50f4adcc7d60d4f73c60eb83ee9e0db8f65823e04",
"arm64": "b5fd13fc8f05ca99292700896d43fa986747a01b5fdc77f7bfc88a3707dc4241",
}
actual_hashes = {}
for architecture, expected_hash in gecko_hashes.items():
with self.subTest(architecture=architecture):
pins = json.loads((HERE / f"versions.debian13-{architecture}.json").read_text(encoding="utf-8"))
self.assertEqual(pins["architecture"], architecture)
self.assertRegex(pins["base_image"], r"@sha256:[0-9a-f]{64}\Z")
self.assertEqual(pins["chromium"]["browser_package_version"], "152.0.7977.82-1~deb13u1")
self.assertEqual(pins["chromium"]["webdriver_package_version"], pins["chromium"]["browser_package_version"])
self.assertEqual(pins["firefox-esr"]["browser_package_version"], "140.15.0esr-1~deb13u1")
self.assertEqual(pins["firefox-esr"]["webdriver_package_version"], "0.36.0")
archive_hash = pins["firefox-esr"]["webdriver_archive_sha256"]
self.assertRegex(archive_hash, r"\A[0-9a-f]{64}\Z")
self.assertEqual(archive_hash, expected_hash)
actual_hashes[architecture] = archive_hash
self.assertNotEqual(actual_hashes.get("amd64"), actual_hashes.get("arm64"))

def test_splitmix_reference_vector_and_javascript_contract(self) -> None:
self.assertEqual(
Expand Down
4 changes: 4 additions & 0 deletions scripts/stealth-browser/versions.debian13-amd64.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"schema_version": 1,
"architecture": "amd64",
"base_image": "debian@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132",
"apt_snapshots": {
"debian": "20260904T145907Z",
"debian_security": "20260905T202337Z"
},
"chromium": {
"browser_package_version": "152.0.7977.82-1~deb13u1",
"webdriver_package_version": "152.0.7977.82-1~deb13u1"
Expand Down
19 changes: 19 additions & 0 deletions scripts/stealth-browser/versions.debian13-arm64.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"schema_version": 1,
"architecture": "arm64",
"base_image": "debian@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132",
"apt_snapshots": {
"debian": "20260904T145907Z",
"debian_security": "20260905T202337Z"
},
"chromium": {
"browser_package_version": "152.0.7977.82-1~deb13u1",
"webdriver_package_version": "152.0.7977.82-1~deb13u1"
},
"firefox-esr": {
"browser_package_version": "140.15.0esr-1~deb13u1",
"webdriver_package_version": "0.36.0",
"webdriver_release_commit": "a3d508507022",
"webdriver_archive_sha256": "b5fd13fc8f05ca99292700896d43fa986747a01b5fdc77f7bfc88a3707dc4241"
}
}
Loading
Loading