Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
42 changes: 42 additions & 0 deletions containers/agent/seccomp-profile.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
20 changes: 20 additions & 0 deletions docs/logging_quickref.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,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`],

Copilot AI Dec 19, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The seccomp profile path is hardcoded in the Docker Compose configuration, but the seccomp file copy logic allows for the file to not exist (only logging a warning). This creates a disconnect where the Docker Compose configuration will always reference the seccomp profile at line 273, even if the file wasn't successfully copied.

This needs to be coordinated with the file copy logic - either make the seccomp profile mandatory and fail if it's missing, or conditionally add the security_opt configuration only when the file exists.

Copilot uses AI. Check for mistakes.
// 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
Expand Down Expand Up @@ -351,6 +366,25 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
};
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,
Expand Down
39 changes: 39 additions & 0 deletions src/host-iptables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions src/host-iptables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
63 changes: 61 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,14 +472,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
*
Expand Down
Loading