diff --git a/README.md b/README.md index dfe24aa..b657757 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ no files exist, the addon disables itself. **Merge rules:** - `env` — merged; higher-precedence values overwrite lower ones. - `secrets` — merged; higher-precedence entries overwrite lower ones. +- `extra_hosts` — merged; higher-precedence entries overwrite lower ones. - `network` — concatenated; highest-precedence rules come first. Since rules are evaluated top-to-bottom with first-match-wins, this means local rules take priority over project rules, which take priority over user rules. @@ -333,6 +334,42 @@ With the liberal template rules: - `POST` to `example.com` → **denied** - Empty network list → all requests **denied** (default deny) +## Resolving internal hostnames + +DNS inside the sandbox goes through mitmproxy to public resolvers (`1.1.1.1`, +`8.8.8.8`), so names that only live in your host's `/etc/hosts` or on an +internal network won't resolve. Use `extra_hosts` in `settings.json` to map +hostnames to IPs: + +```json +{ + "extra_hosts": { + "maven-proxy.corp": "192.168.1.100", + "jira.internal": "10.0.0.50" + }, + "network": [ + {"action": "allow", "host": "maven-proxy.corp"}, + {"action": "allow", "host": "jira.internal"} + ] +} +``` + +The mitmproxy addon writes these entries to a file on the shared volume, and +`wg-client` appends them to its `/etc/hosts` at startup. The agent container +inherits wg-client's `/etc/hosts` via `network_mode: service:wg-client`, so +`getent`, `curl`, Maven, etc. resolve the name via NSS before any DNS query. + +**A matching network allow rule is still required** — mitmproxy enforces +network policy on the HTTP `Host`/SNI regardless of how the name was resolved. +Without an allow rule, requests to the internal host will be blocked with 403. + +**Supported values:** IPv4 and IPv6 addresses. Invalid entries are logged as +warnings and skipped; the container still starts. + +**Applying changes:** `sandcat restart-proxy` re-reads settings and recreates +the sandcat entries in `/etc/hosts`. Processes already running inside the agent +won't pick up the change until they reconnect (or the agent is restarted). + ## Secret substitution Dev containers never see real secret values. Instead, environment variables diff --git a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py index 0e401b9..59300d7 100644 --- a/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py +++ b/cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py @@ -6,13 +6,14 @@ On startup, reads settings from up to three layers (lowest to highest precedence): user (~/.config/sandcat/settings.json), project (.sandcat/settings.json), and local (.sandcat/settings.local.json). -Env vars and secrets are merged (higher precedence wins on conflict). -Network rules are concatenated (highest precedence first). +Env vars, secrets, and extra_hosts are merged (higher precedence wins +on conflict). Network rules are concatenated (highest precedence first). Network rules are evaluated top-to-bottom, first match wins, default deny. Secret placeholders are replaced with real values only for allowed hosts. """ +import ipaddress import json import logging import os @@ -24,6 +25,7 @@ from mitmproxy import ctx, http, dns _VALID_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_VALID_HOSTNAME = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$") # Settings layers, lowest to highest precedence. SETTINGS_PATHS = [ @@ -32,6 +34,7 @@ "/config/project/settings.local.json", # local: .sandcat/settings.local.json ] SANDCAT_ENV_PATH = "/home/mitmproxy/.mitmproxy/sandcat.env" +EXTRA_HOSTS_PATH = "/home/mitmproxy/.mitmproxy/extra_hosts" logger = logging.getLogger(__name__) @@ -59,6 +62,7 @@ def load(self, loader): self._load_secrets(merged["secrets"]) self._load_network_rules(merged["network"]) self._write_placeholders_env() + self._write_extra_hosts(merged["extra_hosts"]) ctx.log.info( f"Loaded {len(self.env)} env var(s) and {len(self.secrets)} secret(s), wrote {SANDCAT_ENV_PATH}" @@ -76,17 +80,20 @@ def _merge_settings(layers: list[dict]) -> dict: - env: dict merge, higher precedence overwrites. - secrets: dict merge, higher precedence overwrites. + - extra_hosts: dict merge, higher precedence overwrites. - network: concatenated, highest precedence first. - op_service_account_token: highest precedence non-empty value wins. """ env: dict[str, str] = {} secrets: dict[str, dict] = {} + extra_hosts: dict[str, str] = {} network: list[dict] = [] op_token: str | None = None for layer in layers: env.update(layer.get("env", {})) secrets.update(layer.get("secrets", {})) + extra_hosts.update(layer.get("extra_hosts", {})) layer_token = layer.get("op_service_account_token") if layer_token: op_token = layer_token @@ -95,8 +102,8 @@ def _merge_settings(layers: list[dict]) -> dict: for layer in reversed(layers): network.extend(layer.get("network", [])) - return {"env": env, "secrets": secrets, "network": network, - "op_service_account_token": op_token} + return {"env": env, "secrets": secrets, "extra_hosts": extra_hosts, + "network": network, "op_service_account_token": op_token} def _load_secrets(self, raw_secrets: dict): for name, entry in raw_secrets.items(): @@ -188,6 +195,31 @@ def _write_placeholders_env(self): with open(SANDCAT_ENV_PATH, "w") as f: f.write("\n".join(lines) + "\n") + @staticmethod + def _write_extra_hosts(extra_hosts: dict[str, str]): + """Write extra_hosts entries as an /etc/hosts-format file. + + Consumed by wg-client-init.sh, which appends the file to /etc/hosts. + Invalid entries are logged and skipped; the container still starts. + """ + lines = [] + for name, address in extra_hosts.items(): + if not isinstance(name, str) or not _VALID_HOSTNAME.match(name): + ctx.log.warn(f"extra_hosts: invalid hostname {name!r}, skipping") + continue + if not isinstance(address, str): + ctx.log.warn(f"extra_hosts[{name!r}]: value must be a string, skipping") + continue + try: + ipaddress.ip_address(address) + except ValueError: + ctx.log.warn(f"extra_hosts[{name!r}]: {address!r} is not a valid IP, skipping") + continue + lines.append(f"{address}\t{name}") + with open(EXTRA_HOSTS_PATH, "w") as f: + if lines: + f.write("\n".join(lines) + "\n") + def _is_request_allowed(self, method: str | None, host: str) -> bool: host = host.lower().rstrip(".") for rule in self.network_rules: diff --git a/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh b/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh index 2361eab..82a95d2 100644 --- a/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh +++ b/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh @@ -126,6 +126,23 @@ if [ -n "$docker_network_v6" ]; then fi ip6tables -A OUTPUT -o eth0 -j DROP +# ── Apply extra_hosts from settings ───────────────────────────────────────── +# The mitmproxy addon writes user-configured extra_hosts entries to the shared +# volume. Append them to /etc/hosts, which is shared with app containers via +# network_mode, so `getent hosts ` resolves them inside the agent. +# Wrapped in a sentinel block so in-place restarts don't accumulate duplicates. +EXTRA_HOSTS_FILE="/mitmproxy-config/extra_hosts" +if [ -f "$EXTRA_HOSTS_FILE" ]; then + sed -i '/^# sandcat extra_hosts BEGIN/,/^# sandcat extra_hosts END/d' /etc/hosts + if [ -s "$EXTRA_HOSTS_FILE" ]; then + { + echo "# sandcat extra_hosts BEGIN" + cat "$EXTRA_HOSTS_FILE" + echo "# sandcat extra_hosts END" + } >> /etc/hosts + fi +fi + # Signal readiness to containers waiting on the healthcheck. touch /tmp/wg-ready diff --git a/cli/test/mitmproxy/test_mitmproxy_addon.py b/cli/test/mitmproxy/test_mitmproxy_addon.py index 0ac4f62..234331a 100644 --- a/cli/test/mitmproxy/test_mitmproxy_addon.py +++ b/cli/test/mitmproxy/test_mitmproxy_addon.py @@ -81,7 +81,16 @@ def make(status, body, headers): sys.modules["mitmproxy"].dns = _dns # Import after mitmproxy stubs are installed in sys.modules above. -from mitmproxy_addon import SandcatAddon, SETTINGS_PATHS # noqa: E402 +from mitmproxy_addon import SandcatAddon, SETTINGS_PATHS, EXTRA_HOSTS_PATH # noqa: E402,F401 + + +@pytest.fixture(autouse=True) +def _redirect_extra_hosts_path(tmp_path_factory): + """Default EXTRA_HOSTS_PATH to a tmp file so load() doesn't touch /home/mitmproxy. + Tests that care about the file's contents re-patch this locally.""" + path = tmp_path_factory.mktemp("extra_hosts_default") / "extra_hosts" + with patch("mitmproxy_addon.EXTRA_HOSTS_PATH", str(path)): + yield def _make_flow(method="GET", host="example.com", url=None, headers=None, content=None): @@ -732,10 +741,71 @@ def test_op_token_absent(self): def test_empty_layers_list(self): merged = SandcatAddon._merge_settings([]) - assert merged == {"env": {}, "secrets": {}, "network": [], - "op_service_account_token": None} + assert merged == {"env": {}, "secrets": {}, "extra_hosts": {}, + "network": [], "op_service_account_token": None} + + def test_extra_hosts_higher_precedence_wins(self): + layers = [ + {"extra_hosts": {"a.corp": "10.0.0.1", "b.corp": "10.0.0.2"}}, + {"extra_hosts": {"b.corp": "10.0.0.99"}}, + ] + merged = SandcatAddon._merge_settings(layers) + assert merged["extra_hosts"] == {"a.corp": "10.0.0.1", "b.corp": "10.0.0.99"} + +class TestExtraHosts: + def _load_with_settings(self, tmp_path, settings): + p = tmp_path / "settings.json" + p.write_text(json.dumps(settings)) + hosts_path = tmp_path / "extra_hosts" + addon = SandcatAddon() + with patch("mitmproxy_addon.SETTINGS_PATHS", [str(p)]), \ + patch("mitmproxy_addon.SANDCAT_ENV_PATH", str(tmp_path / "sandcat.env")), \ + patch("mitmproxy_addon.EXTRA_HOSTS_PATH", str(hosts_path)): + addon.load(MagicMock()) + return hosts_path.read_text() if hosts_path.exists() else None + + def test_writes_hosts_file_in_etc_hosts_format(self, tmp_path): + content = self._load_with_settings(tmp_path, { + "extra_hosts": {"maven.corp": "10.0.0.5", "jira.corp": "10.0.0.6"}, + }) + assert "10.0.0.5\tmaven.corp" in content + assert "10.0.0.6\tjira.corp" in content + + def test_writes_empty_file_when_no_entries(self, tmp_path): + content = self._load_with_settings(tmp_path, { + "network": [{"action": "allow", "host": "*"}], + }) + assert content == "" + + def test_ipv6_address_accepted(self, tmp_path): + content = self._load_with_settings(tmp_path, { + "extra_hosts": {"v6.corp": "2001:db8::1"}, + }) + assert "2001:db8::1\tv6.corp" in content + + def test_invalid_ip_skipped(self, tmp_path): + content = self._load_with_settings(tmp_path, { + "extra_hosts": {"good.corp": "10.0.0.1", "bad.corp": "not-an-ip"}, + }) + assert "10.0.0.1\tgood.corp" in content + assert "bad.corp" not in content + + def test_invalid_hostname_skipped(self, tmp_path): + content = self._load_with_settings(tmp_path, { + "extra_hosts": {"bad host!": "10.0.0.1", "good.corp": "10.0.0.2"}, + }) + assert "10.0.0.2\tgood.corp" in content + assert "bad host!" not in content + + def test_non_string_value_skipped(self, tmp_path): + content = self._load_with_settings(tmp_path, { + "extra_hosts": {"numeric.corp": 42, "good.corp": "10.0.0.2"}, + }) + assert "10.0.0.2\tgood.corp" in content + assert "numeric.corp" not in content + class TestMultiFileLoading: def test_loads_multiple_settings_files(self, tmp_path):