From d09f85f0519069cf424c216c1cfe6c1b90c7df04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:18:05 +0000 Subject: [PATCH 1/3] Initial plan From 3552e46589c7653b27e9d6abe88d882844195e87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:30:49 +0000 Subject: [PATCH 2/3] feat: add blocklist support for domain filtering Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- README.md | 71 ++++++++++++++++++++++++ src/cli.ts | 44 +++++++++++++++ src/docker-manager.ts | 1 + src/squid-config.test.ts | 114 +++++++++++++++++++++++++++++++++++++++ src/squid-config.ts | 79 +++++++++++++++++++++++---- src/types.ts | 26 ++++++++- 6 files changed, 323 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a6b244174..cded17862 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,77 @@ sudo awf \ -- curl https://api.github.com ``` +## Domain Blocklist + +You can explicitly block specific domains using `--block-domains` and `--block-domains-file`. **Blocked domains take precedence over allowed domains**, enabling fine-grained control. + +### Basic Blocklist Usage + +```bash +# Allow example.com but block internal.example.com +sudo awf \ + --allow-domains example.com \ + --block-domains internal.example.com \ + -- curl https://api.example.com # ✓ works + +sudo awf \ + --allow-domains example.com \ + --block-domains internal.example.com \ + -- curl https://internal.example.com # ✗ blocked +``` + +### Blocklist with Wildcards + +```bash +# Allow all of example.com except any subdomain starting with "internal-" +sudo awf \ + --allow-domains example.com \ + --block-domains 'internal-*.example.com' \ + -- curl https://api.example.com # ✓ works + +# Block all subdomains matching the pattern +sudo awf \ + --allow-domains '*.example.com' \ + --block-domains '*.secret.example.com' \ + -- curl https://api.example.com # ✓ works +``` + +### Using a Blocklist File + +```bash +# Create a blocklist file +cat > blocked-domains.txt << 'EOF' +# Internal services that should never be accessed +internal.example.com +admin.example.com + +# Block all subdomains of sensitive.org +*.sensitive.org +EOF + +# Use the blocklist file +sudo awf \ + --allow-domains example.com,sensitive.org \ + --block-domains-file blocked-domains.txt \ + -- curl https://api.example.com +``` + +**Combining flags:** +```bash +# You can combine all domain flags +sudo awf \ + --allow-domains github.com \ + --allow-domains-file allowed.txt \ + --block-domains internal.github.com \ + --block-domains-file blocked.txt \ + -- your-command +``` + +**Use cases:** +- Allow a broad domain (e.g., `*.example.com`) but block specific sensitive subdomains +- Block known bad domains while allowing a curated list +- Prevent access to internal services from AI agents + ## Security Considerations diff --git a/src/cli.ts b/src/cli.ts index 0bf2e75c7..9bcbad72d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -309,6 +309,14 @@ program '--allow-domains-file ', 'Path to file containing allowed domains (one per line or comma-separated, supports # comments)' ) + .option( + '--block-domains ', + 'Comma-separated list of blocked domains (takes precedence over allowed domains). Supports wildcards.' + ) + .option( + '--block-domains-file ', + 'Path to file containing blocked domains (one per line or comma-separated, supports # comments)' + ) .option( '--log-level ', 'Log level: debug, info, warn, error', @@ -457,6 +465,38 @@ program } } + // Parse blocked domains from both --block-domains flag and --block-domains-file + let blockedDomains: string[] = []; + + // Parse blocked domains from command-line flag if provided + if (options.blockDomains) { + blockedDomains = parseDomains(options.blockDomains); + } + + // Parse blocked domains from file if provided + if (options.blockDomainsFile) { + try { + const fileBlockedDomainsArray = parseDomainsFile(options.blockDomainsFile); + blockedDomains.push(...fileBlockedDomainsArray); + } catch (error) { + logger.error(`Failed to read blocked domains file: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + } + + // Remove duplicates from blocked domains + blockedDomains = [...new Set(blockedDomains)]; + + // Validate all blocked domains and patterns + for (const domain of blockedDomains) { + try { + validateDomainOrPattern(domain); + } catch (error) { + logger.error(`Invalid blocked domain or pattern: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + } + // Parse additional environment variables from --env flags let additionalEnv: Record = {}; if (options.env && Array.isArray(options.env)) { @@ -492,6 +532,7 @@ program const config: WrapperConfig = { allowedDomains, + blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined, agentCommand, logLevel, keepContainers: options.keepContainers, @@ -521,6 +562,9 @@ program }; logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2)); logger.info(`Allowed domains: ${allowedDomains.join(', ')}`); + if (blockedDomains.length > 0) { + logger.info(`Blocked domains: ${blockedDomains.join(', ')}`); + } logger.debug(`DNS servers: ${dnsServers.join(', ')}`); let exitCode = 0; diff --git a/src/docker-manager.ts b/src/docker-manager.ts index b9a43817a..81f6671ed 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -404,6 +404,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { // Write Squid config const squidConfig = generateSquidConfig({ domains: config.allowedDomains, + blockedDomains: config.blockedDomains, port: SQUID_PORT, }); const squidConfigPath = path.join(config.workDir, 'squid.conf'); diff --git a/src/squid-config.test.ts b/src/squid-config.test.ts index fb63f912d..a3e07405e 100644 --- a/src/squid-config.test.ts +++ b/src/squid-config.test.ts @@ -692,4 +692,118 @@ describe('generateSquidConfig', () => { expect(result).toContain('# ACL definitions for allowed domain patterns'); }); }); + + describe('Blocklist Support', () => { + it('should generate blocked domain ACL for plain domain', () => { + const config: SquidConfig = { + domains: ['github.com'], + blockedDomains: ['internal.github.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).toContain('acl blocked_domains dstdomain .internal.github.com'); + expect(result).toContain('http_access deny blocked_domains'); + }); + + it('should generate blocked domain ACL for wildcard pattern', () => { + const config: SquidConfig = { + domains: ['example.com'], + blockedDomains: ['*.internal.example.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).toContain('acl blocked_domains_regex dstdom_regex -i'); + expect(result).toContain('^.*\\.internal\\.example\\.com$'); + expect(result).toContain('http_access deny blocked_domains_regex'); + }); + + it('should handle both plain and wildcard blocked domains', () => { + const config: SquidConfig = { + domains: ['example.com'], + blockedDomains: ['internal.example.com', '*.secret.example.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).toContain('acl blocked_domains dstdomain .internal.example.com'); + expect(result).toContain('acl blocked_domains_regex dstdom_regex -i'); + expect(result).toContain('http_access deny blocked_domains'); + expect(result).toContain('http_access deny blocked_domains_regex'); + }); + + it('should place blocked domains deny rule before allowed domains deny rule', () => { + const config: SquidConfig = { + domains: ['github.com'], + blockedDomains: ['internal.github.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + const blockRuleIndex = result.indexOf('http_access deny blocked_domains'); + const allowRuleIndex = result.indexOf('http_access deny !allowed_domains'); + expect(blockRuleIndex).toBeLessThan(allowRuleIndex); + }); + + it('should include blocklist comment section', () => { + const config: SquidConfig = { + domains: ['github.com'], + blockedDomains: ['internal.github.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).toContain('# ACL definitions for blocked domains'); + expect(result).toContain('# Deny requests to blocked domains (blocklist takes precedence)'); + }); + + it('should work without blocklist (backward compatibility)', () => { + const config: SquidConfig = { + domains: ['github.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).not.toContain('blocked_domains'); + expect(result).toContain('acl allowed_domains dstdomain .github.com'); + }); + + it('should work with empty blocklist', () => { + const config: SquidConfig = { + domains: ['github.com'], + blockedDomains: [], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).not.toContain('blocked_domains'); + expect(result).toContain('acl allowed_domains dstdomain .github.com'); + }); + + it('should normalize blocked domains (remove protocol)', () => { + const config: SquidConfig = { + domains: ['github.com'], + blockedDomains: ['https://internal.github.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).toContain('acl blocked_domains dstdomain .internal.github.com'); + expect(result).not.toContain('https://'); + }); + + it('should handle multiple blocked domains', () => { + const config: SquidConfig = { + domains: ['example.com'], + blockedDomains: ['internal.example.com', 'secret.example.com', 'admin.example.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).toContain('acl blocked_domains dstdomain .internal.example.com'); + expect(result).toContain('acl blocked_domains dstdomain .secret.example.com'); + expect(result).toContain('acl blocked_domains dstdomain .admin.example.com'); + }); + + it('should throw error for invalid blocked domain pattern', () => { + const config: SquidConfig = { + domains: ['github.com'], + blockedDomains: ['*'], + port: defaultPort, + }; + expect(() => generateSquidConfig(config)).toThrow(); + }); + }); }); diff --git a/src/squid-config.ts b/src/squid-config.ts index 7e6e22e71..b1af19030 100644 --- a/src/squid-config.ts +++ b/src/squid-config.ts @@ -5,18 +5,21 @@ import { } from './domain-patterns'; /** - * Generates Squid proxy configuration with domain whitelisting + * Generates Squid proxy configuration with domain whitelisting and optional blocklisting * * Supports both plain domains and wildcard patterns: * - Plain domains use dstdomain ACL (efficient, fast matching) * - Wildcard patterns use dstdom_regex ACL (regex matching) + * + * Blocked domains take precedence over allowed domains. * * @example * // Plain domain: github.com -> acl allowed_domains dstdomain .github.com * // Wildcard: *.github.com -> acl allowed_domains_regex dstdom_regex -i ^.*\.github\.com$ + * // Blocked: internal.example.com -> acl blocked_domains dstdomain .internal.example.com */ export function generateSquidConfig(config: SquidConfig): string { - const { domains, port } = config; + const { domains, blockedDomains, port } = config; // Normalize domains - remove protocol if present const normalizedDomains = domains.map(domain => { @@ -57,28 +60,84 @@ export function generateSquidConfig(config: SquidConfig): string { .map(p => `acl allowed_domains_regex dstdom_regex -i ${p.regex}`) .join('\n'); - // Determine the ACL section and deny rule based on what we have - let aclSection = ''; + // Process blocked domains (optional) + let blockedAclSection = ''; + let blockRule = ''; + + if (blockedDomains && blockedDomains.length > 0) { + // Normalize blocked domains + const normalizedBlockedDomains = blockedDomains.map(domain => { + return domain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + }); + + // Parse blocked domains into plain domains and wildcard patterns + const { plainDomains: blockedPlainDomains, patterns: blockedPatterns } = parseDomainList(normalizedBlockedDomains); + + // Generate ACL entries for blocked plain domains + const blockedDomainAcls = blockedPlainDomains + .map(domain => { + const domainPattern = domain.startsWith('.') ? domain : `.${domain}`; + return `acl blocked_domains dstdomain ${domainPattern}`; + }) + .join('\n'); + + // Generate ACL entries for blocked wildcard patterns + const blockedPatternAcls = blockedPatterns + .map(p => `acl blocked_domains_regex dstdom_regex -i ${p.regex}`) + .join('\n'); + + // Build blocked ACL section + const blockedSections: string[] = []; + if (blockedPlainDomains.length > 0) { + blockedSections.push(`# ACL definitions for blocked domains\n${blockedDomainAcls}`); + } + if (blockedPatterns.length > 0) { + blockedSections.push(`# ACL definitions for blocked domain patterns (wildcard)\n${blockedPatternAcls}`); + } + blockedAclSection = blockedSections.join('\n\n'); + + // Build block rule (blocked domains are denied first, before checking allowed domains) + if (blockedPlainDomains.length > 0 && blockedPatterns.length > 0) { + blockRule = 'http_access deny blocked_domains\nhttp_access deny blocked_domains_regex'; + } else if (blockedPlainDomains.length > 0) { + blockRule = 'http_access deny blocked_domains'; + } else if (blockedPatterns.length > 0) { + blockRule = 'http_access deny blocked_domains_regex'; + } + } + + // Determine the ACL section and deny rule based on what we have for allowed domains + let allowedAclSection = ''; let denyRule: string; if (filteredPlainDomains.length > 0 && patterns.length > 0) { // Both plain domains and patterns - aclSection = `# ACL definitions for allowed domains\n${domainAcls}\n\n# ACL definitions for allowed domain patterns (wildcard)\n${patternAcls}`; + allowedAclSection = `# ACL definitions for allowed domains\n${domainAcls}\n\n# ACL definitions for allowed domain patterns (wildcard)\n${patternAcls}`; denyRule = 'http_access deny !allowed_domains !allowed_domains_regex'; } else if (filteredPlainDomains.length > 0) { // Only plain domains - aclSection = `# ACL definitions for allowed domains\n${domainAcls}`; + allowedAclSection = `# ACL definitions for allowed domains\n${domainAcls}`; denyRule = 'http_access deny !allowed_domains'; } else if (patterns.length > 0) { // Only patterns - aclSection = `# ACL definitions for allowed domain patterns (wildcard)\n${patternAcls}`; + allowedAclSection = `# ACL definitions for allowed domain patterns (wildcard)\n${patternAcls}`; denyRule = 'http_access deny !allowed_domains_regex'; } else { // No domains - deny all (edge case, should not happen with validation) - aclSection = '# No domains configured'; + allowedAclSection = '# No domains configured'; denyRule = 'http_access deny all'; } + // Combine blocked and allowed ACL sections + const aclSection = blockedAclSection + ? `${blockedAclSection}\n\n${allowedAclSection}` + : allowedAclSection; + + // Combine access rules: blocked domains first, then allowed domains check + const accessRules = blockRule + ? `# Deny requests to blocked domains (blocklist takes precedence)\n${blockRule}\n\n# Deny requests to unknown domains (not in allow-list)\n# This applies to all sources including localnet\n${denyRule}` + : `# Deny requests to unknown domains (not in allow-list)\n# This applies to all sources including localnet\n${denyRule}`; + return `# Squid configuration for egress traffic control # Generated by awf @@ -115,9 +174,7 @@ acl CONNECT method CONNECT http_access deny !Safe_ports http_access deny CONNECT !SSL_ports -# Deny requests to unknown domains (not in allow-list) -# This applies to all sources including localnet -${denyRule} +${accessRules} # Allow from trusted sources (after domain filtering) http_access allow localnet diff --git a/src/types.ts b/src/types.ts index b28925b18..b2fea3868 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,19 @@ export interface WrapperConfig { */ allowedDomains: string[]; + /** + * List of blocked domains for HTTP/HTTPS egress traffic + * + * Blocked domains take precedence over allowed domains. If a domain matches + * both the allowlist and blocklist, it will be blocked. This allows for + * fine-grained control like allowing '*.example.com' but blocking 'internal.example.com'. + * + * Supports the same wildcard patterns as allowedDomains. + * + * @example ['internal.example.com', '*.sensitive.org'] + */ + blockedDomains?: string[]; + /** * The command to execute inside the firewall container * @@ -239,7 +252,7 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; * * Used to generate squid.conf with domain-based access control lists (ACLs). * The generated configuration implements L7 (application layer) filtering for - * HTTP and HTTPS traffic using domain whitelisting. + * HTTP and HTTPS traffic using domain whitelisting and optional blocklisting. */ export interface SquidConfig { /** @@ -251,6 +264,17 @@ export interface SquidConfig { */ domains: string[]; + /** + * List of blocked domains for proxy access + * + * These domains are explicitly denied. Blocked domains take precedence over + * allowed domains. This allows for fine-grained control like allowing + * '*.example.com' but blocking 'internal.example.com'. + * + * Supports the same wildcard patterns as domains. + */ + blockedDomains?: string[]; + /** * Port number for the Squid proxy to listen on * From ee14a8494b30358c3acc77d0ba76a1f0a84f1333 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:50:23 +0000 Subject: [PATCH 3/3] docs: add blocklist documentation to cli reference and usage guide Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- README.md | 322 ++---------------- containers/agent/seccomp-profile.json | 42 +++ .../content/docs/reference/cli-reference.md | 25 +- docs/logging_quickref.md | 20 ++ docs/usage.md | 100 +++++- src/docker-manager.test.ts | 23 ++ src/docker-manager.ts | 34 ++ src/host-iptables.test.ts | 39 +++ src/host-iptables.ts | 26 ++ src/types.ts | 63 +++- 10 files changed, 391 insertions(+), 303 deletions(-) create mode 100644 containers/agent/seccomp-profile.json diff --git a/README.md b/README.md index cded17862..6bd5f99c2 100644 --- a/README.md +++ b/README.md @@ -5,317 +5,49 @@ 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 -## 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** - -```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 +### GitHub Copilot CLI in one line ```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 -``` - -## Domain Blocklist - -You can explicitly block specific domains using `--block-domains` and `--block-domains-file`. **Blocked domains take precedence over allowed domains**, enabling fine-grained control. - -### Basic Blocklist Usage - -```bash -# Allow example.com but block internal.example.com -sudo awf \ - --allow-domains example.com \ - --block-domains internal.example.com \ - -- curl https://api.example.com # ✓ works - -sudo awf \ - --allow-domains example.com \ - --block-domains internal.example.com \ - -- curl https://internal.example.com # ✗ blocked -``` - -### Blocklist with Wildcards - -```bash -# Allow all of example.com except any subdomain starting with "internal-" -sudo awf \ - --allow-domains example.com \ - --block-domains 'internal-*.example.com' \ - -- curl https://api.example.com # ✓ works - -# Block all subdomains matching the pattern -sudo awf \ - --allow-domains '*.example.com' \ - --block-domains '*.secret.example.com' \ - -- curl https://api.example.com # ✓ works -``` - -### Using a Blocklist File - -```bash -# Create a blocklist file -cat > blocked-domains.txt << 'EOF' -# Internal services that should never be accessed -internal.example.com -admin.example.com - -# Block all subdomains of sensitive.org -*.sensitive.org -EOF - -# Use the blocklist file -sudo awf \ - --allow-domains example.com,sensitive.org \ - --block-domains-file blocked-domains.txt \ - -- curl https://api.example.com ``` -**Combining flags:** -```bash -# You can combine all domain flags -sudo awf \ - --allow-domains github.com \ - --allow-domains-file allowed.txt \ - --block-domains internal.github.com \ - --block-domains-file blocked.txt \ - -- your-command -``` - -**Use cases:** -- Allow a broad domain (e.g., `*.example.com`) but block specific sensitive subdomains -- Block known bad domains while allowing a curated list -- Prevent access to internal services from AI agents - - -## 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 - -### Agent Container Security (User Mode) - -The agent container runs user commands as a **non-root user** (`awfuser`) for enhanced security: - -- **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 -``` +## Explore the docs -## Development & Testing +- [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 -### Running Tests - -```bash -# Install dependencies -npm install +## Development -# 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 000000000..b6a35e706 --- /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-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index 2283f1097..c09bd84af 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -21,6 +21,8 @@ awf [options] -- |--------|------|---------|-------------| | `--allow-domains ` | string | — | Comma-separated list of allowed domains (required unless `--allow-domains-file` used) | | `--allow-domains-file ` | string | — | Path to file containing allowed domains | +| `--block-domains ` | string | — | Comma-separated list of blocked domains (takes precedence over allowed) | +| `--block-domains-file ` | string | — | Path to file containing blocked domains | | `--log-level ` | string | `info` | Logging verbosity: `debug`, `info`, `warn`, `error` | | `--keep-containers` | flag | `false` | Keep containers running after command exits | | `--tty` | flag | `false` | Allocate pseudo-TTY for interactive tools | @@ -40,10 +42,11 @@ awf [options] -- ### `--allow-domains ` -Comma-separated list of allowed domains. Domains automatically match all subdomains. +Comma-separated list of allowed domains. Domains automatically match all subdomains. Supports wildcard patterns. ```bash --allow-domains github.com,npmjs.org +--allow-domains '*.github.com,api-*.example.com' ``` ### `--allow-domains-file ` @@ -54,6 +57,26 @@ Path to file with allowed domains. Supports comments (`#`) and one domain per li --allow-domains-file ./allowed-domains.txt ``` +### `--block-domains ` + +Comma-separated list of blocked domains. **Blocked domains take precedence over allowed domains**, enabling fine-grained control. Supports the same wildcard patterns as `--allow-domains`. + +```bash +# Block specific subdomain while allowing parent domain +--allow-domains example.com --block-domains internal.example.com + +# Block with wildcards +--allow-domains '*.example.com' --block-domains '*.secret.example.com' +``` + +### `--block-domains-file ` + +Path to file with blocked domains. Supports the same format as `--allow-domains-file`. + +```bash +--block-domains-file ./blocked-domains.txt +``` + ### `--log-level ` Set logging verbosity. diff --git a/docs/logging_quickref.md b/docs/logging_quickref.md index dae72d27e..24a106c66 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 290871950..eb7329a23 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -121,6 +121,34 @@ Domains automatically match all subdomains: 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` | + ### Multiple Domains ```bash @@ -155,17 +183,79 @@ For MCP servers: mcp.deepwiki.com ``` -## Limitations +## Domain Blocklist -### No Wildcard Syntax +You can explicitly block specific domains using `--block-domains` and `--block-domains-file`. **Blocked domains take precedence over allowed domains**, enabling fine-grained control. -Wildcards are not needed - subdomains match automatically: +### Basic Blocklist Usage ```bash ---allow-domains '*.github.com' # ✗ syntax not supported ---allow-domains github.com # ✓ matches *.github.com automatically +# Allow example.com but block internal.example.com +sudo awf \ + --allow-domains example.com \ + --block-domains internal.example.com \ + -- curl https://api.example.com # ✓ works + +sudo awf \ + --allow-domains example.com \ + --block-domains internal.example.com \ + -- curl https://internal.example.com # ✗ blocked ``` +### Blocklist with Wildcards + +```bash +# Allow all of example.com except any subdomain starting with "internal-" +sudo awf \ + --allow-domains example.com \ + --block-domains 'internal-*.example.com' \ + -- curl https://api.example.com # ✓ works + +# Block all subdomains matching the pattern +sudo awf \ + --allow-domains '*.example.com' \ + --block-domains '*.secret.example.com' \ + -- curl https://api.example.com # ✓ works +``` + +### Using a Blocklist File + +```bash +# Create a blocklist file +cat > blocked-domains.txt << 'EOF' +# Internal services that should never be accessed +internal.example.com +admin.example.com + +# Block all subdomains of sensitive.org +*.sensitive.org +EOF + +# Use the blocklist file +sudo awf \ + --allow-domains example.com,sensitive.org \ + --block-domains-file blocked-domains.txt \ + -- curl https://api.example.com +``` + +**Combining flags:** +```bash +# You can combine all domain flags +sudo awf \ + --allow-domains github.com \ + --allow-domains-file allowed.txt \ + --block-domains internal.github.com \ + --block-domains-file blocked.txt \ + -- your-command +``` + +**Use cases:** +- Allow a broad domain (e.g., `*.example.com`) but block specific sensitive subdomains +- Block known bad domains while allowing a curated list +- Prevent access to internal services from AI agents + +## Limitations + ### No Internationalized Domains Use punycode instead: diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index cd1d0ec00..9ba81e584 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; diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 81f6671ed..60980ceeb 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -305,6 +305,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 +416,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 090b4707b..4d03f620e 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 fb312233c..1e72214bd 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 b2fea3868..8bf6af97b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -496,14 +496,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 *