diff --git a/docs/environment.md b/docs/environment.md index 79623641a..7e3e6577c 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -159,9 +159,10 @@ The following environment variables are set internally by the firewall and used | `AWF_HOST_PATH` | Host PATH passed to chroot environment | `/usr/local/bin:/usr/bin` | | `AWF_SESSION_STATE_DIR` | Directory for Copilot CLI session state output (equivalent to `--session-state-dir`) | *(unset)* | | `AWF_DIND` | Operator hint that AWF is running in a split runner/daemon (ARC/DinD) filesystem. Set to `1` to trigger the DinD warning when `--docker-host-path-prefix` is missing. See [arc-dind.md](arc-dind.md). | `1` | +| `AWF_SKIP_CAP_DROP` | Last-resort escape hatch: set to `1`, `true`, or `yes` to remove every `cap_drop` directive from generated Docker Compose configurations, including `ALL` on otherwise capability-free proxy services. Normally AWF filters only capabilities unavailable to the Docker daemon. | `1` | | `NO_PROXY` | Domains bypassing Squid (host access mode) | `localhost,host.docker.internal` | -**Note:** Most of these are set automatically based on CLI options and should not be overridden manually. `AWF_SESSION_STATE_DIR` is an exception — it is the environment-variable equivalent of `--session-state-dir` and can be set by users to configure a predictable session-state output path. +**Note:** Most of these are set automatically based on CLI options and should not be overridden manually. `AWF_SESSION_STATE_DIR` is an exception — it is the environment-variable equivalent of `--session-state-dir` and can be set by users to configure a predictable session-state output path. `AWF_SKIP_CAP_DROP` is a host-side emergency escape hatch, not a normal user configuration option. ## GitHub Actions `setup-*` Tool Availability diff --git a/src/capability-filter.test.ts b/src/capability-filter.test.ts new file mode 100644 index 000000000..070f64b11 --- /dev/null +++ b/src/capability-filter.test.ts @@ -0,0 +1,233 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + getHostCapabilityBoundingSet, + isCapDropSkipped, + filterCapDrop, + filterComposeCapDrop, + LINUX_CAPABILITY_MAP, +} from './capability-filter'; +import { DockerComposeConfig } from './types'; +import { generateDockerCompose } from './compose-generator'; +import { WrapperConfig } from './types'; + +describe('capability-filter', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.AWF_SKIP_CAP_DROP; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('LINUX_CAPABILITY_MAP', () => { + it('maps standard Linux capabilities correctly', () => { + expect(LINUX_CAPABILITY_MAP.NET_RAW).toBe(13); + expect(LINUX_CAPABILITY_MAP.SYS_MODULE).toBe(16); + expect(LINUX_CAPABILITY_MAP.SYS_ADMIN).toBe(21); + expect(LINUX_CAPABILITY_MAP.SYS_BOOT).toBe(22); + }); + }); + + describe('isCapDropSkipped', () => { + it('returns false when AWF_SKIP_CAP_DROP is unset', () => { + expect(isCapDropSkipped()).toBe(false); + }); + + it('returns true when AWF_SKIP_CAP_DROP is set to truthy values', () => { + for (const val of ['1', 'true', 'yes', 'TRUE', 'YES']) { + process.env.AWF_SKIP_CAP_DROP = val; + expect(isCapDropSkipped()).toBe(true); + } + }); + + it('returns false when AWF_SKIP_CAP_DROP is set to non-truthy values', () => { + for (const val of ['0', 'false', 'no']) { + process.env.AWF_SKIP_CAP_DROP = val; + expect(isCapDropSkipped()).toBe(false); + } + }); + }); + + describe('getHostCapabilityBoundingSet', () => { + it('returns null when the daemon probe is unavailable', () => { + expect(getHostCapabilityBoundingSet('/nonexistent:latest')).toBeNull(); + }); + + it('does not read the CLI process status', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-test-')); + const procFile = path.join(tmpDir, 'status'); + try { + fs.writeFileSync(procFile, 'Name:\tbash\nCapBnd:\t000001ffffffffff\nCapEff:\t0000000000000000\n'); + expect(getHostCapabilityBoundingSet(procFile)).toBeNull(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('filterCapDrop', () => { + it('returns empty array when input is empty or undefined', () => { + expect(filterCapDrop(undefined)).toEqual([]); + expect(filterCapDrop([])).toEqual([]); + }); + + it('returns empty array when AWF_SKIP_CAP_DROP is set', () => { + process.env.AWF_SKIP_CAP_DROP = '1'; + expect(filterCapDrop(['NET_RAW', 'SYS_MODULE'])).toEqual([]); + }); + + it('returns unmodified list when capBnd is null', () => { + const list = ['NET_RAW', 'SYS_MODULE', 'SYS_ADMIN']; + expect(filterCapDrop(list, null)).toEqual(list); + }); + + it('retains capabilities present in CapBnd and filters out missing ones', () => { + // Full CapBnd (all 41 bits set) + const fullCapBnd = 0x000001ffffffffffn; + const list = ['NET_RAW', 'SYS_MODULE', 'SYS_ADMIN', 'MKNOD']; + expect(filterCapDrop(list, fullCapBnd)).toEqual(list); + + // Trimmed CapBnd: bit 16 (SYS_MODULE) cleared + const sysModuleBit = 1n << 16n; + const trimmedCapBnd = fullCapBnd & ~sysModuleBit; + + expect(filterCapDrop(list, trimmedCapBnd)).toEqual(['NET_RAW', 'SYS_ADMIN', 'MKNOD']); + }); + + it('handles CAP_ prefix and case insensitivity', () => { + const fullCapBnd = 0x000001ffffffffffn; + const sysModuleBit = 1n << 16n; + const trimmedCapBnd = fullCapBnd & ~sysModuleBit; + + const list = ['CAP_NET_RAW', 'cap_sys_module', 'CAP_SYS_ADMIN']; + expect(filterCapDrop(list, trimmedCapBnd)).toEqual(['CAP_NET_RAW', 'CAP_SYS_ADMIN']); + }); + + it('preserves ALL wildcard', () => { + const trimmedCapBnd = 0n; + expect(filterCapDrop(['ALL'], trimmedCapBnd)).toEqual(['ALL']); + }); + + it('preserves unknown capability names', () => { + const trimmedCapBnd = 0n; + expect(filterCapDrop(['UNKNOWN_CUSTOM_CAP'], trimmedCapBnd)).toEqual(['UNKNOWN_CUSTOM_CAP']); + }); + }); + + describe('filterComposeCapDrop', () => { + it('filters cap_drop across all services in compose config', () => { + const compose = { + version: '3.8', + networks: {}, + services: { + 'squid-proxy': { + container_name: 'awf-squid', + cap_drop: ['NET_RAW', 'SYS_ADMIN', 'SYS_MODULE'], + }, + agent: { + container_name: 'awf-agent', + cap_drop: ['NET_RAW', 'SYS_MODULE'], + }, + 'api-proxy': { + container_name: 'awf-api-proxy', + cap_drop: ['ALL'], + }, + }, + } as unknown as DockerComposeConfig; + + // Trim SYS_MODULE (bit 16) + const fullCapBnd = 0x000001ffffffffffn; + const sysModuleBit = 1n << 16n; + const trimmedCapBnd = fullCapBnd & ~sysModuleBit; + + const filtered = filterComposeCapDrop(compose, trimmedCapBnd); + expect(filtered.services['squid-proxy'].cap_drop).toEqual(['NET_RAW', 'SYS_ADMIN']); + expect(filtered.services.agent.cap_drop).toEqual(['NET_RAW']); + expect(filtered.services['api-proxy'].cap_drop).toEqual(['ALL']); + }); + + it('deletes cap_drop key if all capabilities are filtered out', () => { + const compose = { + version: '3.8', + networks: {}, + services: { + agent: { + container_name: 'awf-agent', + cap_drop: ['SYS_MODULE'], + }, + }, + } as unknown as DockerComposeConfig; + + // Trim SYS_MODULE (bit 16) + const fullCapBnd = 0x000001ffffffffffn; + const sysModuleBit = 1n << 16n; + const trimmedCapBnd = fullCapBnd & ~sysModuleBit; + + const filtered = filterComposeCapDrop(compose, trimmedCapBnd); + expect(filtered.services.agent.cap_drop).toBeUndefined(); + }); + + it('deletes cap_drop from all services when AWF_SKIP_CAP_DROP is set', () => { + process.env.AWF_SKIP_CAP_DROP = 'true'; + const compose = { + version: '3.8', + networks: {}, + services: { + 'squid-proxy': { + container_name: 'awf-squid', + cap_drop: ['NET_RAW', 'SYS_ADMIN', 'SYS_MODULE'], + }, + agent: { + container_name: 'awf-agent', + cap_drop: ['NET_RAW', 'SYS_MODULE'], + }, + 'api-proxy': { + container_name: 'awf-api-proxy', + cap_drop: ['ALL'], + }, + }, + } as unknown as DockerComposeConfig; + + const filtered = filterComposeCapDrop(compose); + expect(filtered.services['squid-proxy'].cap_drop).toBeUndefined(); + expect(filtered.services.agent.cap_drop).toBeUndefined(); + expect(filtered.services['api-proxy'].cap_drop).toBeUndefined(); + }); + }); + + describe('integration with generateDockerCompose', () => { + it('removes cap_drop from generated compose when AWF_SKIP_CAP_DROP is set', () => { + process.env.AWF_SKIP_CAP_DROP = '1'; + const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-cap-test-')); + try { + const mockConfig: WrapperConfig = { + allowedDomains: ['github.com'], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + workDir: tmpWorkDir, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }; + const mockNetworkConfig = { + subnet: '172.30.0.0/24', + squidIp: '172.30.0.10', + agentIp: '172.30.0.20', + }; + + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + for (const service of Object.values(result.services)) { + expect(service.cap_drop).toBeUndefined(); + } + } finally { + fs.rmSync(tmpWorkDir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/capability-filter.ts b/src/capability-filter.ts new file mode 100644 index 000000000..4ca8e9b11 --- /dev/null +++ b/src/capability-filter.ts @@ -0,0 +1,166 @@ +import { execFileSync } from 'child_process'; +import { logger } from './logger'; +import type { DockerComposeConfig } from './types'; +import { getLocalDockerEnv } from './docker-host'; + +/** + * Map of standard Linux capability names (uppercase, without CAP_ prefix) to bit positions in CapBnd. + * Reference: Linux kernel include/uapi/linux/capability.h + */ +export const LINUX_CAPABILITY_MAP: Record = { + CHOWN: 0, + DAC_OVERRIDE: 1, + DAC_READ_SEARCH: 2, + FOWNER: 3, + FSETID: 4, + KILL: 5, + SETGID: 6, + SETUID: 7, + SETPCAP: 8, + LINUX_IMMUTABLE: 9, + NET_BIND_SERVICE: 10, + NET_BROADCAST: 11, + NET_ADMIN: 12, + NET_RAW: 13, + IPC_LOCK: 14, + IPC_OWNER: 15, + SYS_MODULE: 16, + SYS_RAWIO: 17, + SYS_CHROOT: 18, + SYS_PTRACE: 19, + SYS_PACCT: 20, + SYS_ADMIN: 21, + SYS_BOOT: 22, + SYS_NICE: 23, + SYS_RESOURCE: 24, + SYS_TIME: 25, + SYS_TTY_CONFIG: 26, + MKNOD: 27, + LEASE: 28, + AUDIT_WRITE: 29, + AUDIT_CONTROL: 30, + SETFCAP: 31, + MAC_OVERRIDE: 32, + MAC_ADMIN: 33, + SYSLOG: 34, + WAKE_ALARM: 35, + BLOCK_SUSPEND: 36, + AUDIT_READ: 37, + PERFMON: 38, + BPF: 39, + CHECKPOINT_RESTORE: 40, +}; + +/** + * Checks whether the AWF_SKIP_CAP_DROP environment variable is set to a truthy value. + */ +export function isCapDropSkipped(): boolean { + const val = process.env.AWF_SKIP_CAP_DROP; + if (!val) return false; + const lower = val.trim().toLowerCase(); + return lower === '1' || lower === 'true' || lower === 'yes'; +} + +/** + * Parses a Linux capability bounding set from status text. + */ +function parseCapabilityBoundingSet(content: string): bigint | null { + const match = content.match(/^CapBnd:\s*([0-9a-fA-F]+)$/m); + if (!match) return null; + try { + return BigInt('0x' + match[1]); + } catch { + return null; + } +} + +/** + * Reads the capability bounding set from a privileged container created by the + * Docker daemon used by AWF. This is deliberately daemon-side: the CLI and a + * sibling ARC/DinD daemon can have different bounding sets. + */ +export function getHostCapabilityBoundingSet(probeImage = 'alpine:latest'): bigint | null { + try { + const content = execFileSync( + 'docker', + ['run', '--rm', '--pull=never', '--privileged', '--network=none', '--entrypoint', '/bin/sh', probeImage, '-c', 'cat /proc/self/status'], + { env: getLocalDockerEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }, + ); + return parseCapabilityBoundingSet(content); + } catch { + return null; + } +} + +/** + * Filters a cap_drop list against host capabilities and environment settings. + * + * - If AWF_SKIP_CAP_DROP is set to 1/true/yes, returns an empty array. + * - If CapBnd cannot be read (null), returns the original list unmodified. + * - Keeps 'ALL' wildcard intact. + * - Filters out explicit capability names not present in the capability bounding set. + */ +export function filterCapDrop(capDropList?: string[], capBndOverride?: bigint | null): string[] { + if (!capDropList || capDropList.length === 0) { + return []; + } + + if (isCapDropSkipped()) { + logger.debug('AWF_SKIP_CAP_DROP is set: removing cap_drop requirements'); + return []; + } + + const capBnd = capBndOverride !== undefined ? capBndOverride : getHostCapabilityBoundingSet(); + if (capBnd === null) { + return capDropList; + } + + return capDropList.filter((cap) => { + const norm = cap.trim().toUpperCase(); + if (norm === 'ALL') { + return true; + } + const capName = norm.replace(/^CAP_/, ''); + const bitIndex = LINUX_CAPABILITY_MAP[capName]; + if (bitIndex === undefined) { + // Unknown capability name; preserve it by default + return true; + } + const capBit = 1n << BigInt(bitIndex); + const isPresent = (capBnd & capBit) !== 0n; + if (!isPresent) { + logger.debug(`Filtering capability '${cap}' from cap_drop: not present in host capability bounding set`); + } + return isPresent; + }); +} + +/** + * Filters cap_drop in all services of a Docker Compose configuration against + * the host capability bounding set (or AWF_SKIP_CAP_DROP). + */ +export function filterComposeCapDrop( + composeConfig: DockerComposeConfig, + capBndOverride?: bigint | null, +): DockerComposeConfig { + if (!composeConfig?.services) { + return composeConfig; + } + const probeImage = Object.values(composeConfig.services) + .map((service) => service?.image) + .find((image): image is string => typeof image === 'string'); + const capBnd = capBndOverride !== undefined + ? capBndOverride + : getHostCapabilityBoundingSet(probeImage); + for (const service of Object.values(composeConfig.services)) { + if (service && Array.isArray(service.cap_drop)) { + const filtered = filterCapDrop(service.cap_drop, capBnd); + if (filtered.length === 0) { + delete service.cap_drop; + } else { + service.cap_drop = filtered; + } + } + } + return composeConfig; +} diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 92feb4499..9c2293aec 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -24,6 +24,7 @@ import { ENCLAVE_MCP_CONTROL_NETWORK, } from './enclave/network'; import { buildInternalServiceHosts } from './services/internal-service-hosts'; +import { filterComposeCapDrop } from './capability-filter'; /** * Generates Docker Compose configuration @@ -234,7 +235,7 @@ export function generateDockerCompose( internal: true, }; } - return compose; + return filterComposeCapDrop(compose); } /** diff --git a/src/docker-manager-reexports.test.ts b/src/docker-manager-reexports.test.ts index 155a37d0c..1bebac119 100644 --- a/src/docker-manager-reexports.test.ts +++ b/src/docker-manager-reexports.test.ts @@ -28,6 +28,7 @@ import * as artifactPreservation from './artifact-preservation'; import * as containerCleanup from './container-cleanup'; import * as containerStop from './container-stop'; import * as diagnosticCollector from './diagnostic-collector'; +import * as capabilityFilter from './capability-filter'; describe('docker-manager re-exports', () => { describe('host-env re-exports', () => { @@ -81,4 +82,22 @@ describe('docker-manager re-exports', () => { expect(dockerManager.cleanup).toBe(containerCleanup.cleanup); }); }); + + describe('capability-filter re-exports', () => { + it('re-exports filterCapDrop', () => { + expect(dockerManager.filterCapDrop).toBe(capabilityFilter.filterCapDrop); + }); + + it('re-exports filterComposeCapDrop', () => { + expect(dockerManager.filterComposeCapDrop).toBe(capabilityFilter.filterComposeCapDrop); + }); + + it('re-exports getHostCapabilityBoundingSet', () => { + expect(dockerManager.getHostCapabilityBoundingSet).toBe(capabilityFilter.getHostCapabilityBoundingSet); + }); + + it('re-exports isCapDropSkipped', () => { + expect(dockerManager.isCapDropSkipped).toBe(capabilityFilter.isCapDropSkipped); + }); + }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index ab222ef01..62959c434 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -22,3 +22,10 @@ export { preserveIptablesAudit, cleanup, } from './container-cleanup'; + +export { + filterCapDrop, + filterComposeCapDrop, + getHostCapabilityBoundingSet, + isCapDropSkipped, +} from './capability-filter';