Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ 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` | Set to `1`, `true`, or `yes` to disable specifying `cap_drop` requirements in generated Docker Compose configurations (useful for hosts with trimmed capability bounding sets). | `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.
Expand Down
254 changes: 254 additions & 0 deletions src/capability-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
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('parses real /proc/self/status on Linux if available', () => {
if (fs.existsSync('/proc/self/status')) {
const bnd = getHostCapabilityBoundingSet();
expect(typeof bnd === 'bigint' || bnd === null).toBe(true);
}
});

it('parses mock proc status file with valid CapBnd', () => {
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');
const bnd = getHostCapabilityBoundingSet(procFile);
expect(bnd).toBe(0x000001ffffffffffn);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it('returns null when proc status file does not exist', () => {
const bnd = getHostCapabilityBoundingSet('/nonexistent/proc/status/path');
expect(bnd).toBeNull();
});

it('returns null when CapBnd is missing from file', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-test-'));
const procFile = path.join(tmpDir, 'status');
try {
fs.writeFileSync(procFile, 'Name:\tbash\n');
const bnd = getHostCapabilityBoundingSet(procFile);
expect(bnd).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 });
}
});
});
});
Loading