diff --git a/README.md b/README.md index a6b2441..a97c240 100644 --- a/README.md +++ b/README.md @@ -5,246 +5,50 @@ A network firewall for agentic workflows with domain whitelisting. This tool pro > [!TIP] > This project is a part of GitHub Next's explorations of [Agentic Workflows](https://github.com/githubnext/gh-aw). For more background, check out the [project page on the GitHub Next website](https://githubnext.com/projects/agentic-workflows/)! ✨ -## Features +## What it does - **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 +- **Optional Docker Isolation**: Disable Docker socket access with `--no-docker` for additional security -## Quick Start +## Get started fast -### Requirements +- **Requirement:** Docker running on your machine +- **Install:** + ```bash + curl -sSL https://raw.githubusercontent.com/githubnext/gh-aw-firewall/main/install.sh | sudo bash + ``` + Review the script before running, or download the latest release binary and verify it with the published `checksums.txt` before installing. +- **Run your first command:** + ```bash + sudo awf --allow-domains github.com -- curl https://api.github.com + ``` + Use the `--` separator to pass the command you want to run behind the firewall. -- **Docker**: Must be running - -### Installation - -**Recommended: One-line installer with SHA verification** +### GitHub Copilot CLI in one line ```bash -curl -sSL https://raw.githubusercontent.com/githubnext/gh-aw-firewall/main/install.sh | sudo bash -``` - -This installer automatically: -- Downloads the latest release binary -- Verifies SHA256 checksum to detect corruption or tampering -- Validates the file is a valid Linux executable -- Protects against 404 error pages being saved as binaries -- Installs to `/usr/local/bin/awf` - -**Alternative: Manual installation** - -```bash -# Download the latest release binary -curl -fL https://github.com/githubnext/gh-aw-firewall/releases/latest/download/awf-linux-x64 -o awf - -# Download checksums for verification -curl -fL https://github.com/githubnext/gh-aw-firewall/releases/latest/download/checksums.txt -o checksums.txt - -# Verify SHA256 checksum -sha256sum -c checksums.txt --ignore-missing - -# Install -chmod +x awf -sudo mv awf /usr/local/bin/ - -# Verify installation -sudo awf --help -``` - -**Docker Image Verification:** All published container images are cryptographically signed with cosign. See [docs/image-verification.md](docs/image-verification.md) for verification instructions. - -### Basic Usage - -```bash -# Simple HTTP request -sudo awf \ - --allow-domains github.com,api.github.com \ - -- curl https://api.github.com - -# With GitHub Copilot CLI sudo -E awf \ - --allow-domains github.com,api.github.com,googleapis.com \ + --allow-domains github.com,api.github.com,githubusercontent.com \ -- copilot --prompt "List my repositories" - -# Docker-in-Docker (spawned containers inherit firewall) -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 -``` - -**Note:** Always use the `--` separator to pass commands and arguments. This ensures proper argument handling and avoids shell escaping issues. - -### Log Viewing - -View Squid proxy logs from current or previous runs: - -```bash -# View recent logs with pretty formatting -awf logs - -# Follow logs in real-time -awf logs -f - -# View logs in JSON format for scripting -awf logs --format json - -# List all available log sources -awf logs --list -``` - -## Domain Whitelisting - -Domains automatically match all subdomains: - -```bash -# github.com matches api.github.com, raw.githubusercontent.com, etc. -sudo awf --allow-domains github.com -- curl https://api.github.com # ✓ works -``` - -### Wildcard Patterns - -You can use wildcard patterns with `*` to match multiple domains: - -```bash -# Match any subdomain of github.com ---allow-domains '*.github.com' - -# Match api-v1.example.com, api-v2.example.com, etc. ---allow-domains 'api-*.example.com' - -# Combine plain domains and wildcards ---allow-domains 'github.com,*.googleapis.com,api-*.example.com' -``` - -**Pattern rules:** -- `*` matches any characters (converted to regex `.*`) -- Patterns are case-insensitive (DNS is case-insensitive) -- Overly broad patterns like `*`, `*.*`, or `*.*.*` are rejected for security -- Use quotes around patterns to prevent shell expansion - -**Examples:** -| Pattern | Matches | Does Not Match | -|---------|---------|----------------| -| `*.github.com` | `api.github.com`, `raw.github.com` | `github.com` | -| `api-*.example.com` | `api-v1.example.com`, `api-test.example.com` | `api.example.com` | -| `github.com` | `github.com`, `api.github.com` | `notgithub.com` | - -### Using Command-Line Flag - -Common domain lists: - -```bash -# For GitHub Copilot with GitHub API ---allow-domains github.com,api.github.com,githubusercontent.com,googleapis.com - -# For MCP servers ---allow-domains github.com,arxiv.org,example.com ``` -### Using a Domains File - -You can also specify domains in a file using `--allow-domains-file`: - -```bash -# Create a domains file (see examples/domains.txt) -cat > allowed-domains.txt << 'EOF' -# GitHub domains -github.com -api.github.com - -# NPM registry -npmjs.org, registry.npmjs.org - -# Wildcard patterns -*.googleapis.com - -# Example with inline comment -example.com # Example domain -EOF - -# Use the domains file -sudo awf --allow-domains-file allowed-domains.txt -- curl https://api.github.com -``` - -**File format:** -- One domain per line or comma-separated -- Comments start with `#` (full line or inline) -- Empty lines are ignored -- Whitespace is trimmed -- Wildcard patterns are supported - -**Combining both methods:** -```bash -# You can use both flags together - domains are merged -sudo awf \ - --allow-domains github.com \ - --allow-domains-file my-domains.txt \ - -- curl https://api.github.com -``` - - -## Security Considerations - -### What This Protects Against -- Unauthorized egress to non-whitelisted domains -- Data exfiltration via HTTP/HTTPS -- DNS-based data exfiltration to unauthorized DNS servers -- MCP servers accessing unexpected endpoints +## Explore the docs -### Agent Container Security (User Mode) +- [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 +- [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 +- [Architecture](docs/architecture.md) — how Squid, Docker, and iptables fit together +- [Troubleshooting](docs/troubleshooting.md) — common issues and fixes +- [Image verification](docs/image-verification.md) — cosign signature verification -The agent container runs user commands as a **non-root user** (`awfuser`) for enhanced security: +## Development -- **Privilege Separation**: Privileged operations (iptables setup, DNS configuration) run as root in the entrypoint, then privileges are dropped before executing user commands -- **UID/GID Matching**: The `awfuser` UID/GID is automatically adjusted to match the host user's UID/GID, ensuring correct file ownership for mounted volumes -- **Reduced Attack Surface**: If a user command is compromised, it cannot modify system files or escape the container's security boundaries -- **Docker Access**: The `awfuser` is added to the docker group, allowing MCP servers to spawn containers while still running as non-root - -**Note:** The `awf` CLI itself requires `sudo` for host-level iptables configuration (DOCKER-USER chain), but the agent processes (GitHub Copilot CLI, etc.) run without root privileges inside the container. - -### DNS Server Restriction - -DNS traffic is restricted to trusted servers only (default: Google DNS 8.8.8.8, 8.8.4.4). This prevents DNS-based data exfiltration attacks where an attacker encodes data in DNS queries to a malicious DNS server. - -```bash -# Use custom DNS servers -sudo awf \ - --allow-domains github.com \ - --dns-servers 1.1.1.1,1.0.0.1 \ - -- curl https://api.github.com -``` - -## Development & Testing - -### Running Tests - -```bash -# Install dependencies -npm install - -# Run all tests -npm test - -# Run tests with coverage report -npm run test:coverage - -# Run tests in watch mode -npm run test:watch -``` - -### Building - -```bash -# Build TypeScript -npm run build - -# Run linter -npm run lint - -# Clean build artifacts -npm run clean -``` +- Install dependencies: `npm install` +- Run tests: `npm test` +- Build: `npm run build` ## Contributing diff --git a/containers/agent/seccomp-profile.json b/containers/agent/seccomp-profile.json new file mode 100644 index 0000000..b6a35e7 --- /dev/null +++ b/containers/agent/seccomp-profile.json @@ -0,0 +1,42 @@ +{ + "defaultAction": "SCMP_ACT_ALLOW", + "architectures": [ + "SCMP_ARCH_X86_64", + "SCMP_ARCH_X86", + "SCMP_ARCH_AARCH64" + ], + "syscalls": [ + { + "names": [ + "kexec_load", + "kexec_file_load", + "reboot", + "init_module", + "finit_module", + "delete_module", + "acct", + "swapon", + "swapoff", + "mount", + "umount", + "umount2", + "pivot_root", + "syslog", + "add_key", + "request_key", + "keyctl", + "uselib", + "personality", + "ustat", + "sysfs", + "vhangup", + "get_kernel_syms", + "query_module", + "create_module", + "nfsservctl" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1 + } + ] +} diff --git a/docs/logging_quickref.md b/docs/logging_quickref.md index dae72d2..24a106c 100644 --- a/docs/logging_quickref.md +++ b/docs/logging_quickref.md @@ -29,6 +29,26 @@ docker exec awf-agent dmesg | grep FW_BLOCKED sudo journalctl -k | grep FW_BLOCKED ``` +### DNS Query Logging (Audit Trail) +```bash +# View all DNS queries made by containers +sudo dmesg | grep FW_DNS_QUERY + +# Using journalctl (systemd) +sudo journalctl -k | grep FW_DNS_QUERY + +# Real-time DNS query monitoring +sudo dmesg -w | grep FW_DNS_QUERY + +# Count DNS queries by destination +sudo dmesg | grep FW_DNS_QUERY | grep -oP 'DST=\K[^ ]+' | sort | uniq -c | sort -rn + +# Show DNS queries to specific resolver (e.g., 8.8.8.8) +sudo dmesg | grep FW_DNS_QUERY | grep 'DST=8.8.8.8' +``` + +**Note:** DNS queries are logged for audit trail purposes. This helps detect potential DNS tunneling attempts or unusual DNS activity. The log prefix `[FW_DNS_QUERY]` is used to identify DNS traffic. + ## Log Format ### Squid Log Entry diff --git a/docs/usage.md b/docs/usage.md index 2908719..a313502 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,6 +11,7 @@ Options: --log-level Log level: debug, info, warn, error (default: info) --keep-containers Keep containers running after command exits --work-dir Working directory for temporary files + --no-docker Disable Docker-in-Docker: do not mount Docker socket -V, --version Output the version number -h, --help Display help for command @@ -45,6 +46,24 @@ sudo awf \ # Returns: curl: (22) The requested URL returned error: 403 ``` +### Disabling Docker-in-Docker + +By default, the agent container has access to the Docker socket, allowing it to spawn additional containers (which inherit firewall restrictions). For additional security, you can disable this with the `--no-docker` flag: + +```bash +# Disable Docker-in-Docker - Docker socket is not mounted +sudo awf \ + --allow-domains github.com \ + --no-docker \ + -- curl https://api.github.com +``` + +When `--no-docker` is enabled: +- The Docker socket (`/var/run/docker.sock`) is not mounted +- Commands cannot spawn new Docker containers from within the firewall +- Provides additional security by preventing container escapes via Docker +- Useful when running untrusted code that shouldn't have Docker access + ### With GitHub Copilot CLI ```bash diff --git a/src/cli.ts b/src/cli.ts index 0bf2e75..ed4c6c1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -374,6 +374,11 @@ program '--proxy-logs-dir ', 'Directory to save Squid proxy logs to (writes access.log directly to this directory)' ) + .option( + '--no-docker', + 'Disable Docker-in-Docker: do not mount Docker socket, preventing container spawning', + false + ) .argument('[args...]', 'Command and arguments to execute (use -- to separate from options)') .action(async (args: string[], options) => { // Require -- separator for passing command arguments @@ -506,6 +511,7 @@ program containerWorkDir: options.containerWorkdir, dnsServers, proxyLogsDir: options.proxyLogsDir, + disableDocker: options.noDocker, }; // Warn if --env-all is used diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index cd1d0ec..4384514 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -173,6 +173,29 @@ describe('docker-manager', () => { expect(agent.cap_add).toContain('NET_ADMIN'); }); + it('should apply container hardening measures', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const agent = result.services.agent; + + // Verify dropped capabilities for security hardening + expect(agent.cap_drop).toEqual([ + 'NET_RAW', + 'SYS_PTRACE', + 'SYS_MODULE', + 'SYS_RAWIO', + 'MKNOD', + ]); + + // Verify seccomp profile is configured + expect(agent.security_opt).toContain('seccomp=/tmp/awf-test/seccomp-profile.json'); + + // Verify resource limits + expect(agent.mem_limit).toBe('4g'); + expect(agent.memswap_limit).toBe('4g'); + expect(agent.pids_limit).toBe(1000); + expect(agent.cpu_shares).toBe(1024); + }); + it('should disable TTY by default to prevent ANSI escape sequences', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services.agent; @@ -375,5 +398,104 @@ describe('docker-manager', () => { expect(result.services.agent.working_dir).toBe('/var/lib/app/data'); }); }); + + describe('disableDocker option', () => { + it('should mount Docker socket by default (disableDocker not set)', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain('/var/run/docker.sock:/var/run/docker.sock:rw'); + expect(volumes.some((v: string) => v.includes('.docker:/workspace/.docker'))).toBe(true); + }); + + it('should mount Docker socket when disableDocker is false', () => { + const config: WrapperConfig = { + ...mockConfig, + disableDocker: false, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain('/var/run/docker.sock:/var/run/docker.sock:rw'); + expect(volumes.some((v: string) => v.includes('.docker:/workspace/.docker'))).toBe(true); + }); + + it('should NOT mount Docker socket when disableDocker is true', () => { + const config: WrapperConfig = { + ...mockConfig, + disableDocker: true, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).not.toContain('/var/run/docker.sock:/var/run/docker.sock:rw'); + expect(volumes.some((v: string) => v.includes('docker.sock'))).toBe(false); + }); + + it('should NOT mount Docker config when disableDocker is true', () => { + const config: WrapperConfig = { + ...mockConfig, + disableDocker: true, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + // Should not have .docker mounts + expect(volumes.some((v: string) => v.includes('.docker:/workspace/.docker'))).toBe(false); + expect(volumes.some((v: string) => v.includes('.docker:') && v.includes('/.docker:'))).toBe(false); + }); + + it('should still mount essential volumes when disableDocker is true', () => { + const config: WrapperConfig = { + ...mockConfig, + disableDocker: true, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + // Essential mounts should still be present + expect(volumes).toContain('/tmp:/tmp:rw'); + expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); + }); + + it('should work with custom volume mounts when disableDocker is true', () => { + const config: WrapperConfig = { + ...mockConfig, + disableDocker: true, + volumeMounts: ['/workspace:/workspace:ro'], + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + // Custom mount should be present + expect(volumes).toContain('/workspace:/workspace:ro'); + // Docker socket should still be absent + expect(volumes.some((v: string) => v.includes('docker.sock'))).toBe(false); + }); + + it('should NOT set Docker environment variables when disableDocker is true', () => { + const config: WrapperConfig = { + ...mockConfig, + disableDocker: true, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.DOCKER_HOST).toBeUndefined(); + expect(env.DOCKER_CONTEXT).toBeUndefined(); + }); + + it('should set Docker environment variables when disableDocker is false', () => { + const config: WrapperConfig = { + ...mockConfig, + disableDocker: false, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.DOCKER_HOST).toBe('unix:///var/run/docker.sock'); + expect(env.DOCKER_CONTEXT).toBe('default'); + }); + }); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index b9a4381..05182a6 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -221,10 +221,14 @@ 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', }; + // Only set Docker environment variables when Docker is enabled + if (!config.disableDocker) { + environment.DOCKER_HOST = 'unix:///var/run/docker.sock'; + environment.DOCKER_CONTEXT = 'default'; + } + // If --env-all is specified, pass through all host environment variables (except excluded ones) if (config.envAll) { for (const [key, value] of Object.entries(process.env)) { @@ -264,16 +268,22 @@ export function generateDockerCompose( // Essential mounts that are always included '/tmp:/tmp:rw', `${process.env.HOME}:${process.env.HOME}:rw`, + // Mount agent logs directory to workDir for persistence + `${config.workDir}/agent-logs:${process.env.HOME}/.copilot/logs:rw`, + ]; + + // Mount Docker socket and config unless --no-docker is specified + if (!config.disableDocker) { // Mount Docker socket for MCP servers that need to run containers - '/var/run/docker.sock:/var/run/docker.sock:rw', + agentVolumes.push('/var/run/docker.sock:/var/run/docker.sock:rw'); // Mount clean Docker config to override host's context - `${config.workDir}/.docker:/workspace/.docker:rw`, + agentVolumes.push(`${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`, - ]; + agentVolumes.push(`${config.workDir}/.docker:${process.env.HOME}/.docker:rw`); + } else { + logger.info('Docker-in-Docker disabled: Docker socket not mounted'); + } // Add custom volume mounts if specified if (config.volumeMounts && config.volumeMounts.length > 0) { @@ -305,6 +315,21 @@ export function generateDockerCompose( }, }, cap_add: ['NET_ADMIN'], // Required for iptables + // Drop capabilities to reduce attack surface (security hardening) + cap_drop: [ + 'NET_RAW', // Prevents raw socket creation (iptables bypass attempts) + 'SYS_PTRACE', // Prevents process inspection/debugging (container escape vector) + 'SYS_MODULE', // Prevents kernel module loading + 'SYS_RAWIO', // Prevents raw I/O access + 'MKNOD', // Prevents device node creation + ], + // Apply seccomp profile to restrict dangerous syscalls + security_opt: [`seccomp=${config.workDir}/seccomp-profile.json`], + // Resource limits to prevent DoS attacks (conservative defaults) + mem_limit: '4g', // 4GB memory limit + memswap_limit: '4g', // No swap (same as mem_limit) + pids_limit: 1000, // Max 1000 processes + cpu_shares: 1024, // Default CPU share stdin_open: true, tty: config.tty || false, // Use --tty flag, default to false for clean logs // Escape $ with $$ for Docker Compose variable interpolation @@ -401,6 +426,25 @@ export async function writeConfigs(config: WrapperConfig): Promise { }; logger.debug(`Using network config: ${networkConfig.subnet} (squid: ${networkConfig.squidIp}, agent: ${networkConfig.agentIp})`); + // Copy seccomp profile to work directory for container security + const seccompSourcePath = path.join(__dirname, '..', 'containers', 'agent', 'seccomp-profile.json'); + const seccompDestPath = path.join(config.workDir, 'seccomp-profile.json'); + if (fs.existsSync(seccompSourcePath)) { + fs.copyFileSync(seccompSourcePath, seccompDestPath); + logger.debug(`Seccomp profile written to: ${seccompDestPath}`); + } else { + // If running from dist, try relative to dist + const altSeccompPath = path.join(__dirname, '..', '..', 'containers', 'agent', 'seccomp-profile.json'); + if (fs.existsSync(altSeccompPath)) { + fs.copyFileSync(altSeccompPath, seccompDestPath); + logger.debug(`Seccomp profile written to: ${seccompDestPath}`); + } else { + const message = `Seccomp profile not found at ${seccompSourcePath} or ${altSeccompPath}. Container security hardening requires the seccomp profile.`; + logger.error(message); + throw new Error(message); + } + } + // Write Squid config const squidConfig = generateSquidConfig({ domains: config.allowedDomains, diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index 090b470..4d03f62 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -140,6 +140,19 @@ describe('host-iptables', () => { '-j', 'ACCEPT', ]); + // Verify DNS query logging rules (LOG before ACCEPT for audit trail) + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'udp', '-d', '8.8.8.8', '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '8.8.8.8', '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + // Verify DNS rules for trusted servers only expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', @@ -153,6 +166,19 @@ describe('host-iptables', () => { '-j', 'ACCEPT', ]); + // Verify DNS query logging rules for second DNS server + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'udp', '-d', '8.8.4.4', '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ + '-t', 'filter', '-A', 'FW_WRAPPER', + '-p', 'tcp', '-d', '8.8.4.4', '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + expect(mockedExeca).toHaveBeenCalledWith('iptables', [ '-t', 'filter', '-A', 'FW_WRAPPER', '-p', 'udp', '-d', '8.8.4.4', '--dport', '53', @@ -419,6 +445,19 @@ describe('host-iptables', () => { '-j', 'ACCEPT', ]); + // Verify IPv6 DNS query logging rules (LOG before ACCEPT) + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-p', 'udp', '-d', '2001:4860:4860::8888', '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + + expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ + '-t', 'filter', '-A', 'FW_WRAPPER_V6', + '-p', 'tcp', '-d', '2001:4860:4860::8888', '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + // Verify IPv6 DNS rule uses ip6tables expect(mockedExeca).toHaveBeenCalledWith('ip6tables', [ '-t', 'filter', '-A', 'FW_WRAPPER_V6', diff --git a/src/host-iptables.ts b/src/host-iptables.ts index fb31223..1e72214 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -276,12 +276,25 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS // Add IPv4 DNS server rules using iptables for (const dnsServer of ipv4DnsServers) { + // Log DNS queries first (LOG doesn't terminate processing) + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'udp', '-d', dnsServer, '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + await execa('iptables', [ '-t', 'filter', '-A', CHAIN_NAME, '-p', 'udp', '-d', dnsServer, '--dport', '53', '-j', 'ACCEPT', ]); + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'tcp', '-d', dnsServer, '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + await execa('iptables', [ '-t', 'filter', '-A', CHAIN_NAME, '-p', 'tcp', '-d', dnsServer, '--dport', '53', @@ -336,12 +349,25 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS // 4. Allow DNS ONLY to specified trusted IPv6 DNS servers for (const dnsServer of ipv6DnsServers) { + // Log DNS queries first (LOG doesn't terminate processing) + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'udp', '-d', dnsServer, '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, '-p', 'udp', '-d', dnsServer, '--dport', '53', '-j', 'ACCEPT', ]); + await execa('ip6tables', [ + '-t', 'filter', '-A', CHAIN_NAME_V6, + '-p', 'tcp', '-d', dnsServer, '--dport', '53', + '-j', 'LOG', '--log-prefix', '[FW_DNS_QUERY] ', '--log-level', '4', + ]); + await execa('ip6tables', [ '-t', 'filter', '-A', CHAIN_NAME_V6, '-p', 'tcp', '-d', dnsServer, '--dport', '53', diff --git a/src/types.ts b/src/types.ts index b28925b..d0e57db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -220,6 +220,23 @@ export interface WrapperConfig { * @example '/tmp/my-proxy-logs' */ proxyLogsDir?: string; + + /** + * Whether to disable Docker-in-Docker functionality + * + * When true: + * - Docker socket is NOT mounted into the agent container + * - Commands cannot spawn new Docker containers from within the firewall + * - Provides additional security by preventing container escapes via Docker + * + * When false (default): + * - Docker socket is mounted at /var/run/docker.sock + * - Commands can spawn new containers (with firewall restrictions) + * - Spawned containers are subject to the same network restrictions + * + * @default false + */ + disableDocker?: boolean; } /** @@ -472,14 +489,73 @@ export interface DockerService { /** * Linux capabilities to add to the container - * + * * Grants additional privileges beyond the default container capabilities. * The agent container requires NET_ADMIN for iptables manipulation. - * + * * @example ['NET_ADMIN'] */ cap_add?: string[]; + /** + * Linux capabilities to drop from the container + * + * Removes specific capabilities to reduce attack surface. The firewall drops + * capabilities that could be used for container escape or firewall bypass. + * + * @example ['NET_RAW', 'SYS_PTRACE', 'SYS_MODULE'] + */ + cap_drop?: string[]; + + /** + * Security options for the container + * + * Used for seccomp profiles, AppArmor profiles, and other security configurations. + * + * @example ['seccomp=/path/to/profile.json'] + */ + security_opt?: string[]; + + /** + * Memory limit for the container + * + * Maximum amount of memory the container can use. Prevents DoS attacks + * via memory exhaustion. + * + * @example '4g' + * @example '512m' + */ + mem_limit?: string; + + /** + * Total memory limit including swap + * + * Set equal to mem_limit to disable swap usage. + * + * @example '4g' + */ + memswap_limit?: string; + + /** + * Maximum number of PIDs (processes) in the container + * + * Limits fork bombs and process exhaustion attacks. + * + * @example 1000 + */ + pids_limit?: number; + + /** + * CPU shares (relative weight) + * + * Controls CPU allocation relative to other containers. + * Default is 1024. + * + * @example 1024 + * @example 512 + */ + cpu_shares?: number; + /** * Keep STDIN open even if not attached *