diff --git a/SECURITY.md b/SECURITY.md index 8a49b98..6e7859e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,6 @@ Secrets passed as env vars via `env.sh` in the workdir. Only visible inside the - Bash builtins (`echo`, `test`, `[`, `eval`, `source`) bypass PATH wrappers and the DEBUG trap. They are not subject to `deny.cli` and won't be tracked for insist enforcement. - DNS-based iptables rules resolve hostnames at execution time. CDN IP rotation mid-request may cause connection failure. -- IPv6 traffic is not filtered. Only IPv4 iptables rules are applied — AAAA-resolvable hosts reachable via IPv6 are not subject to `allow.net`/`deny.net`. +- Network restrictions are enforced with `iptables` and `ip6tables`; DNS-based rules resolve both A and AAAA records at execution time. - Symlinks inside `allow.read`/`allow.write` directories are followed by the kernel. A symlink at an allowed path that points outside the allowed tree lets the script read or write the target. Avoid mounting directories that contain attacker-controlled symlinks. - `/proc` and `/dev` are mounted inside the sandbox. Scripts can read `/proc/self/*` and other process metadata visible to the unprivileged `band-runner` UID. diff --git a/packages/runtime/src/band-server.ts b/packages/runtime/src/band-server.ts index ae55442..ad04f57 100644 --- a/packages/runtime/src/band-server.ts +++ b/packages/runtime/src/band-server.ts @@ -165,6 +165,24 @@ function writeFile(path: string, content: string): void { // ── Firewall ────────────────────────────────────────────────────────── +const FIREWALL_TABLES = [ + { table: "iptables", resolver: "ahostsv4" }, + { table: "ip6tables", resolver: "ahostsv6" }, +]; + +function addResolvedFirewallRule( + cmds: string[], + chainName: string, + host: string, + target: "ACCEPT" | "REJECT" +): void { + for (const { table, resolver } of FIREWALL_TABLES) { + cmds.push( + `for ip in $(getent ${resolver} "${host}" 2>/dev/null | awk '{print $1}' | sort -u); do ${table} -A ${chainName} -d "$ip" -j ${target}; done` + ); + } +} + function setupFirewall( chainName: string, allowNet: string[], @@ -173,27 +191,29 @@ function setupFirewall( if (allowNet.length === 0 && denyNet.length === 0) return; if (allowNet.includes("*") && denyNet.length === 0) return; - // Check if iptables is available and BAND-DEFAULT chain exists. + // Check if iptables/ip6tables are available. try { - shell("iptables -L BAND-DEFAULT -n >/dev/null 2>&1"); + shell("iptables -L OUTPUT -n >/dev/null 2>&1"); + shell("ip6tables -L OUTPUT -n >/dev/null 2>&1"); } catch { return; } - const cmds: string[] = [ - `iptables -N ${chainName} 2>/dev/null || iptables -F ${chainName}`, - `iptables -A ${chainName} -o lo -j ACCEPT`, - `iptables -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`, - `iptables -A ${chainName} -p udp --dport 53 -j ACCEPT`, - `iptables -A ${chainName} -p tcp --dport 53 -j ACCEPT`, - ]; + const cmds: string[] = []; + for (const { table } of FIREWALL_TABLES) { + cmds.push( + `${table} -N ${chainName} 2>/dev/null || ${table} -F ${chainName}`, + `${table} -A ${chainName} -o lo -j ACCEPT`, + `${table} -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`, + `${table} -A ${chainName} -p udp --dport 53 -j ACCEPT`, + `${table} -A ${chainName} -p tcp --dport 53 -j ACCEPT`, + ); + } // Deny rules FIRST — they punch holes in allow (deny takes precedence) for (const host of denyNet) { const resolveHost = shellSafe(host.startsWith("*.") ? host.slice(2) : host, "deny.net host"); - cmds.push( - `for ip in $(getent ahosts "${resolveHost}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j REJECT; done` - ); + addResolvedFirewallRule(cmds, chainName, resolveHost, "REJECT"); } // Allow rules @@ -201,34 +221,38 @@ function setupFirewall( if (host === "*") continue; if (host.startsWith("*.")) { const base = shellSafe(host.slice(2), "allow.net host"); - cmds.push( - `for ip in $(getent ahosts "${base}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done`, - `for ip in $(getent ahosts "api.${base}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done`, - `for ip in $(getent ahosts "www.${base}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done` - ); + addResolvedFirewallRule(cmds, chainName, base, "ACCEPT"); + addResolvedFirewallRule(cmds, chainName, `api.${base}`, "ACCEPT"); + addResolvedFirewallRule(cmds, chainName, `www.${base}`, "ACCEPT"); } else { const safeHost = shellSafe(host, "allow.net host"); - cmds.push( - `for ip in $(getent ahosts "${safeHost}" 2>/dev/null | awk '{print $1}' | sort -u); do iptables -A ${chainName} -d "$ip" -j ACCEPT; done` - ); + addResolvedFirewallRule(cmds, chainName, safeHost, "ACCEPT"); } } if (!allowNet.includes("*")) { - cmds.push(`iptables -A ${chainName} -j REJECT`); + for (const { table } of FIREWALL_TABLES) { + cmds.push(`${table} -A ${chainName} -j REJECT`); + } } const { uid } = getCachedBandRunnerIds(); - cmds.push(`iptables -I OUTPUT 1 -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName}`); + for (const { table } of FIREWALL_TABLES) { + cmds.push(`${table} -I OUTPUT 1 -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName}`); + } shell(cmds.join("\n")); } function teardownFirewall(chainName: string): void { + const { uid } = getCachedBandRunnerIds(); shellIgnoreError( - `iptables -D OUTPUT -m owner --uid-owner ${getCachedBandRunnerIds().uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` + + `iptables -D OUTPUT -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` + `iptables -F ${chainName} 2>/dev/null; ` + - `iptables -X ${chainName} 2>/dev/null` + `iptables -X ${chainName} 2>/dev/null; ` + + `ip6tables -D OUTPUT -m owner --uid-owner ${uid} -m state --state NEW -j ${chainName} 2>/dev/null; ` + + `ip6tables -F ${chainName} 2>/dev/null; ` + + `ip6tables -X ${chainName} 2>/dev/null` ); } diff --git a/packages/runtime/src/banded-skills/lima-exec-utils.ts b/packages/runtime/src/banded-skills/lima-exec-utils.ts index d1c5d36..aa37550 100644 --- a/packages/runtime/src/banded-skills/lima-exec-utils.ts +++ b/packages/runtime/src/banded-skills/lima-exec-utils.ts @@ -89,7 +89,7 @@ export function extractMountPath(pattern: string): string | null { } /** - * Build an iptables setup script for the given network rules. + * Build an iptables/ip6tables setup script for the given network rules. */ export function buildFirewallScript( chainName: string, @@ -97,45 +97,55 @@ export function buildFirewallScript( ): string | null { if (!rules || rules.allowNet.length === 0) return null; - const lines: string[] = [ - `iptables -N ${chainName} 2>/dev/null || iptables -F ${chainName}`, - `iptables -A ${chainName} -o lo -j ACCEPT`, - `iptables -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`, - `iptables -A ${chainName} -p udp --dport 53 -j ACCEPT`, - `iptables -A ${chainName} -p tcp --dport 53 -j ACCEPT`, + const firewallTables = [ + { table: "iptables", resolver: "ahostsv4" }, + { table: "ip6tables", resolver: "ahostsv6" }, ]; + const lines: string[] = []; + + for (const { table } of firewallTables) { + lines.push( + `${table} -N ${chainName} 2>/dev/null || ${table} -F ${chainName}`, + `${table} -A ${chainName} -o lo -j ACCEPT`, + `${table} -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT`, + `${table} -A ${chainName} -p udp --dport 53 -j ACCEPT`, + `${table} -A ${chainName} -p tcp --dport 53 -j ACCEPT`, + ); + } + + const addHostRules = (host: string) => { + for (const { table, resolver } of firewallTables) { + lines.push( + `for ip in $(getent ${resolver} "${host}" 2>/dev/null | awk '{print $1}' | sort -u); do`, + ` ${table} -A ${chainName} -d "$ip" -j ACCEPT`, + `done`, + ); + } + }; for (const host of rules.allowNet) { if (host === "*") return null; if (host.startsWith("*.")) { const baseDomain = host.slice(2); - lines.push( - `# Allow ${host}`, - `for ip in $(getent ahosts "${baseDomain}" 2>/dev/null | awk '{print $1}' | sort -u); do`, - ` iptables -A ${chainName} -d "$ip" -j ACCEPT`, - `done`, - ); + lines.push(`# Allow ${host}`); + addHostRules(baseDomain); for (const prefix of ["api", "www"]) { - lines.push( - `for ip in $(getent ahosts "${prefix}.${baseDomain}" 2>/dev/null | awk '{print $1}' | sort -u); do`, - ` iptables -A ${chainName} -d "$ip" -j ACCEPT`, - `done`, - ); + addHostRules(`${prefix}.${baseDomain}`); } } else { - lines.push( - `# Allow ${host}`, - `for ip in $(getent ahosts "${host}" 2>/dev/null | awk '{print $1}' | sort -u); do`, - ` iptables -A ${chainName} -d "$ip" -j ACCEPT`, - `done`, - ); + lines.push(`# Allow ${host}`); + addHostRules(host); } } - lines.push(`iptables -A ${chainName} -j REJECT`); + for (const { table } of firewallTables) { + lines.push(`${table} -A ${chainName} -j REJECT`); + } // Only route band-runner's new connections through this chain - lines.push(`iptables -I OUTPUT 1 -m state --state NEW -j ${chainName}`); + for (const { table } of firewallTables) { + lines.push(`${table} -I OUTPUT 1 -m state --state NEW -j ${chainName}`); + } return lines.join("\n"); } diff --git a/packages/runtime/src/banded-skills/lima-exec.ts b/packages/runtime/src/banded-skills/lima-exec.ts index 30daa2c..6851f55 100644 --- a/packages/runtime/src/banded-skills/lima-exec.ts +++ b/packages/runtime/src/banded-skills/lima-exec.ts @@ -12,13 +12,48 @@ */ import { execSync } from "child_process"; -import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "fs"; +import { existsSync, readFileSync, writeFileSync, mkdtempSync, mkdirSync, rmSync, statSync } from "fs"; import { join, basename } from "path"; import { tmpdir } from "os"; import type { BandExecResult } from "./types"; const DEFAULT_VM_NAME = "bands-executor"; const SERVER_URL = "http://localhost:9000"; +const EXEC_LOCK_DIR = join(tmpdir(), "bands-lima-exec.lock"); +const EXEC_LOCK_TIMESTAMP = join(EXEC_LOCK_DIR, "created-at"); +const EXEC_LOCK_STALE_MS = 120_000; + +export function acquireExecLockSync(timeoutMs = 150_000): () => void { + const deadline = Date.now() + timeoutMs; + while (true) { + try { + mkdirSync(EXEC_LOCK_DIR); + writeFileSync(EXEC_LOCK_TIMESTAMP, String(Date.now())); + return () => rmSync(EXEC_LOCK_DIR, { recursive: true, force: true }); + } catch (e: any) { + if (e?.code !== "EEXIST") throw e; + + try { + const parsedCreatedAt = Number(readFileSync(EXEC_LOCK_TIMESTAMP, "utf-8")); + const createdAt = Number.isNaN(parsedCreatedAt) + ? statSync(EXEC_LOCK_DIR).mtimeMs + : parsedCreatedAt; + if (Date.now() - createdAt > EXEC_LOCK_STALE_MS) { + rmSync(EXEC_LOCK_DIR, { recursive: true, force: true }); + continue; + } + } catch { + continue; + } + + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for exclusive access to the band server"); + } + const waitUntil = Date.now() + 100; + while (Date.now() < waitUntil) {} + } + } +} /** * Execute a script in the Lima VM via the band server. @@ -91,9 +126,12 @@ export async function limaExec( insist: fileRules?.insist, }; - // POST to the band server + // POST to the band server. The Lima server accepts one execution at a time; + // serialize cross-process callers so parallel test files do not race it. let resp: Response; + let releaseLock: (() => void) | undefined; try { + releaseLock = acquireExecLockSync(); resp = await fetch(`${SERVER_URL}/exec`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -105,6 +143,8 @@ export async function limaExec( success: false, error: `Failed to reach band server at ${SERVER_URL}: ${e.message}. Is the Lima VM running?`, }; + } finally { + releaseLock?.(); } let result: { diff --git a/packages/runtime/src/setup.ts b/packages/runtime/src/setup.ts index 8175abb..ee2c5f6 100644 --- a/packages/runtime/src/setup.ts +++ b/packages/runtime/src/setup.ts @@ -179,21 +179,23 @@ export async function setupLima(options: { force?: boolean } = {}): Promise/dev/null || true`); - limaShell(`sudo iptables -P OUTPUT ACCEPT`); - // Create a persistent chain for band-runner traffic - limaShell(`sudo iptables -N BAND-DEFAULT 2>/dev/null || sudo iptables -F BAND-DEFAULT`); - limaShell(`sudo iptables -A BAND-DEFAULT -o lo -j ACCEPT`); - limaShell(`sudo iptables -A BAND-DEFAULT -m state --state ESTABLISHED,RELATED -j ACCEPT`); - limaShell(`sudo iptables -A BAND-DEFAULT -p udp --dport 53 -j ACCEPT`); - limaShell(`sudo iptables -A BAND-DEFAULT -p tcp --dport 53 -j ACCEPT`); - limaShell(`sudo iptables -A BAND-DEFAULT -j REJECT`); - // Route band-runner's outbound traffic through the restrictive chain - limaShell(`sudo iptables -A OUTPUT -m owner --uid-owner ${bandRunnerUid} -j BAND-DEFAULT`); + for (const table of ["iptables", "ip6tables"]) { + limaShell(`sudo ${table} -F OUTPUT 2>/dev/null || true`); + limaShell(`sudo ${table} -P OUTPUT ACCEPT`); + // Create a persistent chain for band-runner traffic + limaShell(`sudo ${table} -N BAND-DEFAULT 2>/dev/null || sudo ${table} -F BAND-DEFAULT`); + limaShell(`sudo ${table} -A BAND-DEFAULT -o lo -j ACCEPT`); + limaShell(`sudo ${table} -A BAND-DEFAULT -m state --state ESTABLISHED,RELATED -j ACCEPT`); + limaShell(`sudo ${table} -A BAND-DEFAULT -p udp --dport 53 -j ACCEPT`); + limaShell(`sudo ${table} -A BAND-DEFAULT -p tcp --dport 53 -j ACCEPT`); + limaShell(`sudo ${table} -A BAND-DEFAULT -j REJECT`); + // Route band-runner's outbound traffic through the restrictive chain + limaShell(`sudo ${table} -A OUTPUT -m owner --uid-owner ${bandRunnerUid} -j BAND-DEFAULT`); + } log(" ", `Default firewall: REJECT all outbound from band-runner (uid ${bandRunnerUid})`); const healthy = await pollHealth(`http://localhost:${PORT}`, 15_000); diff --git a/packages/runtime/test/unit/banded-skills/lima-exec-utils.test.ts b/packages/runtime/test/unit/banded-skills/lima-exec-utils.test.ts index ebc5ec1..11b0b86 100644 --- a/packages/runtime/test/unit/banded-skills/lima-exec-utils.test.ts +++ b/packages/runtime/test/unit/banded-skills/lima-exec-utils.test.ts @@ -346,6 +346,9 @@ describe("buildFirewallScript", () => { expect(script).toContain( "iptables -N BAND_X 2>/dev/null || iptables -F BAND_X" ); + expect(script).toContain( + "ip6tables -N BAND_X 2>/dev/null || ip6tables -F BAND_X" + ); }); test("allows loopback traffic", () => { @@ -381,8 +384,10 @@ describe("buildFirewallScript", () => { denyNet: [], })!; expect(script).toContain("# Allow api.example.com"); - expect(script).toContain('getent ahosts "api.example.com"'); + expect(script).toContain('getent ahostsv4 "api.example.com"'); + expect(script).toContain('getent ahostsv6 "api.example.com"'); expect(script).toContain("iptables -A BAND_X -d \"$ip\" -j ACCEPT"); + expect(script).toContain("ip6tables -A BAND_X -d \"$ip\" -j ACCEPT"); }); test("handles wildcard domain (*.example.com)", () => { @@ -392,10 +397,10 @@ describe("buildFirewallScript", () => { })!; expect(script).toContain("# Allow *.example.com"); // Should resolve the base domain - expect(script).toContain('getent ahosts "example.com"'); + expect(script).toContain('getent ahostsv4 "example.com"'); // Should also resolve common subdomains - expect(script).toContain('getent ahosts "api.example.com"'); - expect(script).toContain('getent ahosts "www.example.com"'); + expect(script).toContain('getent ahostsv4 "api.example.com"'); + expect(script).toContain('getent ahostsv4 "www.example.com"'); }); test("wildcard domain resolves base and api/www prefixes", () => { @@ -403,9 +408,9 @@ describe("buildFirewallScript", () => { allowNet: ["*.myservice.io"], denyNet: [], })!; - expect(script).toContain('getent ahosts "myservice.io"'); - expect(script).toContain('getent ahosts "api.myservice.io"'); - expect(script).toContain('getent ahosts "www.myservice.io"'); + expect(script).toContain('getent ahostsv4 "myservice.io"'); + expect(script).toContain('getent ahostsv4 "api.myservice.io"'); + expect(script).toContain('getent ahostsv4 "www.myservice.io"'); }); test("ends with REJECT rule", () => { @@ -429,15 +434,18 @@ describe("buildFirewallScript", () => { ); }); - test("OUTPUT rule is the last line", () => { + test("OUTPUT rules are the last lines", () => { const script = buildFirewallScript("BAND_X", { allowNet: ["example.com"], denyNet: [], })!; const lines = script.split("\n"); - expect(lines[lines.length - 1]).toBe( + expect(lines[lines.length - 2]).toBe( "iptables -I OUTPUT 1 -m state --state NEW -j BAND_X" ); + expect(lines[lines.length - 1]).toBe( + "ip6tables -I OUTPUT 1 -m state --state NEW -j BAND_X" + ); }); test("handles multiple allowed hosts", () => { @@ -448,9 +456,9 @@ describe("buildFirewallScript", () => { expect(script).toContain("# Allow api.example.com"); expect(script).toContain("# Allow cdn.example.com"); expect(script).toContain("# Allow auth.example.com"); - expect(script).toContain('getent ahosts "api.example.com"'); - expect(script).toContain('getent ahosts "cdn.example.com"'); - expect(script).toContain('getent ahosts "auth.example.com"'); + expect(script).toContain('getent ahostsv4 "api.example.com"'); + expect(script).toContain('getent ahostsv4 "cdn.example.com"'); + expect(script).toContain('getent ahostsv4 "auth.example.com"'); }); test("handles mix of wildcard and simple domains", () => { @@ -459,11 +467,11 @@ describe("buildFirewallScript", () => { denyNet: [], })!; // Wildcard domain - expect(script).toContain('getent ahosts "github.com"'); - expect(script).toContain('getent ahosts "api.github.com"'); - expect(script).toContain('getent ahosts "www.github.com"'); + expect(script).toContain('getent ahostsv4 "github.com"'); + expect(script).toContain('getent ahostsv4 "api.github.com"'); + expect(script).toContain('getent ahostsv4 "www.github.com"'); // Simple domain - expect(script).toContain('getent ahosts "registry.npmjs.org"'); + expect(script).toContain('getent ahostsv4 "registry.npmjs.org"'); }); test("uses the provided chainName throughout", () => { diff --git a/packages/runtime/test/unit/banded-skills/lima-firewall.test.ts b/packages/runtime/test/unit/banded-skills/lima-firewall.test.ts index 25ab3ff..1178a75 100644 --- a/packages/runtime/test/unit/banded-skills/lima-firewall.test.ts +++ b/packages/runtime/test/unit/banded-skills/lima-firewall.test.ts @@ -24,6 +24,7 @@ describe("buildFirewallScript", () => { denyNet: [], }); expect(script).toContain("iptables -N BAND-abc123"); + expect(script).toContain("ip6tables -N BAND-abc123"); }); test("allows loopback", () => { @@ -55,7 +56,8 @@ describe("buildFirewallScript", () => { allowNet: ["api.github.com"], denyNet: [], })!; - expect(script).toContain('getent ahosts "api.github.com"'); + expect(script).toContain('getent ahostsv4 "api.github.com"'); + expect(script).toContain('getent ahostsv6 "api.github.com"'); expect(script).toContain("-j ACCEPT"); }); @@ -64,9 +66,9 @@ describe("buildFirewallScript", () => { allowNet: ["*.github.com"], denyNet: [], })!; - expect(script).toContain('getent ahosts "github.com"'); - expect(script).toContain('getent ahosts "api.github.com"'); - expect(script).toContain('getent ahosts "www.github.com"'); + expect(script).toContain('getent ahostsv4 "github.com"'); + expect(script).toContain('getent ahostsv4 "api.github.com"'); + expect(script).toContain('getent ahostsv4 "www.github.com"'); }); test("ends with REJECT default", () => { @@ -94,9 +96,9 @@ describe("buildFirewallScript", () => { allowNet: ["api.github.com", "slack.com", "pypi.org"], denyNet: [], })!; - expect(script).toContain('getent ahosts "api.github.com"'); - expect(script).toContain('getent ahosts "slack.com"'); - expect(script).toContain('getent ahosts "pypi.org"'); + expect(script).toContain('getent ahostsv4 "api.github.com"'); + expect(script).toContain('getent ahostsv4 "slack.com"'); + expect(script).toContain('getent ahostsv4 "pypi.org"'); }); }); diff --git a/scripts/cli-agent-test-helpers.ts b/scripts/cli-agent-test-helpers.ts index c6c04e2..c5caf79 100644 --- a/scripts/cli-agent-test-helpers.ts +++ b/scripts/cli-agent-test-helpers.ts @@ -15,6 +15,7 @@ import { join, resolve } from "path"; import { tmpdir } from "os"; import { execSync } from "child_process"; import { bandExec } from "../packages/runtime/src/banded-skills/exec"; +import { acquireExecLockSync } from "../packages/runtime/src/banded-skills/lima-exec"; // Load .env from repo root and packages/runtime (both, not just first found) const ENV_PATHS = [ @@ -79,7 +80,9 @@ export function requireLima() { throw new Error("Failed to check Lima VM status. Is limactl installed?"); } + let releaseLock: (() => void) | undefined; try { + releaseLock = acquireExecLockSync(); const resp = execSync("curl -sf --max-time 2 http://localhost:9000/health", { stdio: "pipe", }); @@ -87,6 +90,8 @@ export function requireLima() { throw new Error( "Band server not reachable at localhost:9000. Is the band server running in the Lima VM?" ); + } finally { + releaseLock?.(); } }