Skip to content
Open
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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions cli/templates/devcontainer/sandcat/scripts/mitmproxy_addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = [
Expand All @@ -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__)

Expand Down Expand Up @@ -59,6 +62,7 @@
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}"
Expand All @@ -76,17 +80,20 @@

- 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
Expand All @@ -95,8 +102,8 @@
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():
Expand Down Expand Up @@ -188,6 +195,31 @@
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")

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.

def _is_request_allowed(self, method: str | None, host: str) -> bool:
host = host.lower().rstrip(".")
for rule in self.network_rules:
Expand Down
17 changes: 17 additions & 0 deletions cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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
Comment on lines +135 to +143
fi

# Signal readiness to containers waiting on the healthcheck.
touch /tmp/wg-ready

Expand Down
76 changes: 73 additions & 3 deletions cli/test/mitmproxy/test_mitmproxy_addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading