-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathdocker-manager.ts
More file actions
1067 lines (942 loc) · 41.8 KB
/
docker-manager.ts
File metadata and controls
1067 lines (942 loc) · 41.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as yaml from 'js-yaml';
import execa from 'execa';
import { DockerComposeConfig, WrapperConfig, BlockedTarget } from './types';
import { logger } from './logger';
import { generateSquidConfig } from './squid-config';
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns } from './ssl-bump';
const SQUID_PORT = 3128;
const SQUID_INTERCEPT_PORT = 3129; // Port for transparently intercepted traffic
/**
* Base image for the 'act' preset when building locally.
* Uses catthehacker's GitHub Actions parity image.
*/
export const ACT_PRESET_BASE_IMAGE = 'ghcr.io/catthehacker/ubuntu:act-24.04';
/**
* Minimum UID/GID value for regular users.
* UIDs 0-999 are reserved for system users on most Linux distributions.
*/
export const MIN_REGULAR_UID = 1000;
/**
* Validates that a UID/GID value is safe for use (not in system range).
* Returns the value if valid, or the default (1000) if in system range.
* @internal Exported for testing
*/
export function validateIdNotInSystemRange(id: number): string {
// Reject system UIDs/GIDs (0-999) - use default unprivileged user instead
if (id < MIN_REGULAR_UID) {
return MIN_REGULAR_UID.toString();
}
return id.toString();
}
/**
* Gets the host user's UID, with fallback to 1000 if unavailable, root (0),
* or in the system UID range (0-999).
* When running with sudo, uses SUDO_UID to get the actual user's UID.
* @internal Exported for testing
*/
export function getSafeHostUid(): string {
const uid = process.getuid?.();
// When running as root (sudo), try to get the original user's UID
if (!uid || uid === 0) {
const sudoUid = process.env.SUDO_UID;
if (sudoUid) {
const parsedUid = parseInt(sudoUid, 10);
if (!isNaN(parsedUid)) {
return validateIdNotInSystemRange(parsedUid);
}
}
return MIN_REGULAR_UID.toString();
}
return validateIdNotInSystemRange(uid);
}
/**
* Gets the host user's GID, with fallback to 1000 if unavailable, root (0),
* or in the system GID range (0-999).
* When running with sudo, uses SUDO_GID to get the actual user's GID.
* @internal Exported for testing
*/
export function getSafeHostGid(): string {
const gid = process.getgid?.();
// When running as root (sudo), try to get the original user's GID
if (!gid || gid === 0) {
const sudoGid = process.env.SUDO_GID;
if (sudoGid) {
const parsedGid = parseInt(sudoGid, 10);
if (!isNaN(parsedGid)) {
return validateIdNotInSystemRange(parsedGid);
}
}
return MIN_REGULAR_UID.toString();
}
return validateIdNotInSystemRange(gid);
}
/**
* Gets the real user's home directory, accounting for sudo.
* When running with sudo, uses SUDO_USER to find the actual user's home.
* @internal Exported for testing
*/
export function getRealUserHome(): string {
const uid = process.getuid?.();
// When running as root (sudo), try to get the original user's home
if (!uid || uid === 0) {
// Try SUDO_USER first - look up their home directory from passwd
const sudoUser = process.env.SUDO_USER;
if (sudoUser) {
try {
// Look up user's home directory from /etc/passwd
const passwd = fs.readFileSync('/etc/passwd', 'utf-8');
const userLine = passwd.split('\n').find(line => line.startsWith(`${sudoUser}:`));
if (userLine) {
const parts = userLine.split(':');
if (parts.length >= 6 && parts[5]) {
return parts[5]; // Home directory is the 6th field
}
}
} catch {
// Fall through to use HOME
}
}
}
// Use HOME environment variable as fallback
return process.env.HOME || '/root';
}
/**
* Gets existing Docker network subnets to avoid conflicts
*/
async function getExistingDockerSubnets(): Promise<string[]> {
try {
// Get all network IDs
const { stdout: networkIds } = await execa('docker', ['network', 'ls', '-q']);
if (!networkIds.trim()) {
return [];
}
// Get subnet information for each network
const { stdout } = await execa('docker', [
'network',
'inspect',
'--format={{range .IPAM.Config}}{{.Subnet}} {{end}}',
...networkIds.trim().split('\n'),
]);
// Parse subnets from output (format: "172.17.0.0/16 172.18.0.0/16 ")
const subnets = stdout
.split(/\s+/)
.filter((s) => s.includes('/'))
.map((s) => s.trim());
logger.debug(`Found existing Docker subnets: ${subnets.join(', ')}`);
return subnets;
} catch {
logger.debug('Failed to query Docker networks, proceeding with random subnet');
return [];
}
}
/**
* Checks if two subnets overlap
* Returns true if the new subnet conflicts with an existing subnet
*/
export function subnetsOverlap(subnet1: string, subnet2: string): boolean {
// Parse CIDR notation: "172.17.0.0/16" -> ["172.17.0.0", "16"]
const [ip1, cidr1] = subnet1.split('/');
const [ip2, cidr2] = subnet2.split('/');
// Convert IP to number
const ipToNumber = (ip: string): number => {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
};
// Calculate network address and broadcast address for a subnet
const getNetworkRange = (ip: string, cidr: string): [number, number] => {
const ipNum = ipToNumber(ip);
const maskBits = parseInt(cidr, 10);
const mask = (0xffffffff << (32 - maskBits)) >>> 0;
const networkAddr = (ipNum & mask) >>> 0;
const broadcastAddr = (networkAddr | ~mask) >>> 0;
return [networkAddr, broadcastAddr];
};
const [start1, end1] = getNetworkRange(ip1, cidr1);
const [start2, end2] = getNetworkRange(ip2, cidr2);
// Check if ranges overlap
return (start1 <= end2 && end1 >= start2);
}
/**
* Generates a random subnet in Docker's private IP range that doesn't conflict with existing networks
* Uses 172.16-31.x.0/24 range (Docker's default bridge network range)
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function _generateRandomSubnet(): Promise<{ subnet: string; squidIp: string; agentIp: string }> {
const existingSubnets = await getExistingDockerSubnets();
const MAX_RETRIES = 50;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
// Use 172.16-31.x.0/24 range
const secondOctet = Math.floor(Math.random() * 16) + 16; // 16-31
const thirdOctet = Math.floor(Math.random() * 256); // 0-255
const subnet = `172.${secondOctet}.${thirdOctet}.0/24`;
// Check for conflicts with existing subnets
const hasConflict = existingSubnets.some((existingSubnet) =>
subnetsOverlap(subnet, existingSubnet)
);
if (!hasConflict) {
const squidIp = `172.${secondOctet}.${thirdOctet}.10`;
const agentIp = `172.${secondOctet}.${thirdOctet}.20`;
return { subnet, squidIp, agentIp };
}
logger.debug(`Subnet ${subnet} conflicts with existing network, retrying... (attempt ${attempt + 1}/${MAX_RETRIES})`);
}
throw new Error(
`Failed to generate non-conflicting subnet after ${MAX_RETRIES} attempts. ` +
`Existing subnets: ${existingSubnets.join(', ')}`
);
}
/**
* SSL configuration for Docker Compose (when SSL Bump is enabled)
*/
export interface SslConfig {
caFiles: CaFiles;
sslDbPath: string;
}
/**
* Generates Docker Compose configuration
* Note: Uses external network 'awf-net' created by host-iptables setup
*/
export function generateDockerCompose(
config: WrapperConfig,
networkConfig: { subnet: string; squidIp: string; agentIp: string },
sslConfig?: SslConfig
): DockerComposeConfig {
const projectRoot = path.join(__dirname, '..');
// Default to GHCR images unless buildLocal is explicitly set
const useGHCR = !config.buildLocal;
const registry = config.imageRegistry || 'ghcr.io/github/gh-aw-firewall';
const tag = config.imageTag || 'latest';
// Squid logs path: use proxyLogsDir if specified (direct write), otherwise workDir/squid-logs
const squidLogsPath = config.proxyLogsDir || `${config.workDir}/squid-logs`;
// Build Squid volumes list
const squidVolumes = [
`${config.workDir}/squid.conf:/etc/squid/squid.conf:ro`,
`${squidLogsPath}:/var/log/squid:rw`,
];
// Add SSL-related volumes if SSL Bump is enabled
if (sslConfig) {
squidVolumes.push(`${sslConfig.caFiles.certPath}:${sslConfig.caFiles.certPath}:ro`);
squidVolumes.push(`${sslConfig.caFiles.keyPath}:${sslConfig.caFiles.keyPath}:ro`);
// Mount SSL database at /var/spool/squid_ssl_db (Squid's expected location)
squidVolumes.push(`${sslConfig.sslDbPath}:/var/spool/squid_ssl_db:rw`);
}
// Squid service configuration
const squidService: any = {
container_name: 'awf-squid',
networks: {
'awf-net': {
ipv4_address: networkConfig.squidIp,
},
},
volumes: squidVolumes,
healthcheck: {
test: ['CMD-SHELL', 'nc -z localhost 3128 && nc -z localhost 3129'],
interval: '5s',
timeout: '3s',
retries: 5,
start_period: '10s',
},
ports: [`${SQUID_PORT}:${SQUID_PORT}`, `${SQUID_INTERCEPT_PORT}:${SQUID_INTERCEPT_PORT}`],
// Security hardening: Drop unnecessary capabilities
// Squid only needs network capabilities, not system administration capabilities
cap_drop: [
'NET_RAW', // No raw socket access needed
'SYS_ADMIN', // No system administration needed
'SYS_PTRACE', // No process tracing needed
'SYS_MODULE', // No kernel module loading
'MKNOD', // No device node creation
'AUDIT_WRITE', // No audit log writing
'SETFCAP', // No setting file capabilities
],
};
// Only enable host.docker.internal when explicitly requested via --enable-host-access
// This allows containers to reach services on the host machine (e.g., MCP gateways)
// Security note: When combined with allowing host.docker.internal domain,
// containers can access any port on the host
if (config.enableHostAccess) {
squidService.extra_hosts = ['host.docker.internal:host-gateway'];
logger.debug('Host access enabled: host.docker.internal will resolve to host gateway');
}
// Use GHCR image or build locally
// For SSL Bump, we always build locally to include OpenSSL tools
if (useGHCR && !config.sslBump) {
squidService.image = `${registry}/squid:${tag}`;
} else {
squidService.build = {
context: path.join(projectRoot, 'containers/squid'),
dockerfile: 'Dockerfile',
};
}
// Build environment variables for agent execution container
// System variables that must be overridden or excluded (would break container operation)
const EXCLUDED_ENV_VARS = new Set([
'PATH', // Must use container's PATH
'PWD', // Container's working directory
'OLDPWD', // Not relevant in container
'SHLVL', // Shell level not relevant
'_', // Last command executed
'SUDO_COMMAND', // Sudo metadata
'SUDO_USER', // Sudo metadata
'SUDO_UID', // Sudo metadata
'SUDO_GID', // Sudo metadata
]);
// Start with required/overridden environment variables
// For chroot mode, use the real user's home (not /root when running with sudo)
const homeDir = config.enableChroot ? getRealUserHome() : (process.env.HOME || '/root');
const environment: Record<string, string> = {
HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
SQUID_PROXY_HOST: 'squid-proxy',
SQUID_PROXY_PORT: SQUID_PORT.toString(),
SQUID_INTERCEPT_PORT: SQUID_INTERCEPT_PORT.toString(),
HOME: homeDir,
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
};
// For chroot mode, pass the host's actual PATH and tool directories so the entrypoint can use them
// This ensures toolcache paths (Python, Node, Go, Rust, Java) are correctly resolved
if (config.enableChroot) {
if (process.env.PATH) {
environment.AWF_HOST_PATH = process.env.PATH;
}
// Go on GitHub Actions uses trimmed binaries that require GOROOT to be set
// Pass GOROOT as AWF_GOROOT so entrypoint.sh can export it in the chroot script
if (process.env.GOROOT) {
environment.AWF_GOROOT = process.env.GOROOT;
}
// Rust: Pass CARGO_HOME so entrypoint can add $CARGO_HOME/bin to PATH
if (process.env.CARGO_HOME) {
environment.AWF_CARGO_HOME = process.env.CARGO_HOME;
}
// Java: Pass JAVA_HOME so entrypoint can add $JAVA_HOME/bin to PATH and set JAVA_HOME
if (process.env.JAVA_HOME) {
environment.AWF_JAVA_HOME = process.env.JAVA_HOME;
}
}
// If --env-all is specified, pass through all host environment variables (except excluded ones)
if (config.envAll) {
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !EXCLUDED_ENV_VARS.has(key) && !Object.prototype.hasOwnProperty.call(environment, key)) {
environment[key] = value;
}
}
} else {
// Default behavior: selectively pass through specific variables
if (process.env.GITHUB_TOKEN) environment.GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (process.env.GH_TOKEN) environment.GH_TOKEN = process.env.GH_TOKEN;
if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) environment.GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
// Anthropic API key for Claude Code
if (process.env.ANTHROPIC_API_KEY) environment.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.USER) environment.USER = process.env.USER;
if (process.env.TERM) environment.TERM = process.env.TERM;
if (process.env.XDG_CONFIG_HOME) environment.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
}
// Additional environment variables from --env flags (these override everything)
if (config.additionalEnv) {
Object.assign(environment, config.additionalEnv);
}
// Pass DNS servers to container for setup-iptables.sh and entrypoint.sh
const dnsServers = config.dnsServers || ['8.8.8.8', '8.8.4.4'];
environment.AWF_DNS_SERVERS = dnsServers.join(',');
// Pass allowed ports to container for setup-iptables.sh (if specified)
if (config.allowHostPorts) {
environment.AWF_ALLOW_HOST_PORTS = config.allowHostPorts;
}
// Pass chroot mode flag to container for entrypoint.sh capability drop
if (config.enableChroot) {
environment.AWF_CHROOT_ENABLED = 'true';
// Pass the container working directory for chroot mode
// If containerWorkDir is set, use it; otherwise use home directory
// The entrypoint will strip /host prefix to get the correct path inside chroot
if (config.containerWorkDir) {
environment.AWF_WORKDIR = config.containerWorkDir;
} else {
// Default to real user's home directory (not /root when running with sudo)
environment.AWF_WORKDIR = getRealUserHome();
}
}
// Pass host UID/GID for runtime user adjustment in entrypoint
// This ensures awfuser UID/GID matches host user for correct file ownership
environment.AWF_USER_UID = getSafeHostUid();
environment.AWF_USER_GID = getSafeHostGid();
// Note: UID/GID values are logged by the container entrypoint if needed for debugging
// Build volumes list for agent execution container
// For chroot mode, use the real user's home (not /root when running with sudo)
const effectiveHome = config.enableChroot ? getRealUserHome() : (process.env.HOME || '/root');
const agentVolumes: string[] = [
// Essential mounts that are always included
'/tmp:/tmp:rw',
`${effectiveHome}:${effectiveHome}:rw`,
// Mount agent logs directory to workDir for persistence
`${config.workDir}/agent-logs:${effectiveHome}/.copilot/logs:rw`,
];
// Add chroot-related volume mounts when --enable-chroot is specified
// These mounts enable chroot /host to work properly for running host binaries
if (config.enableChroot) {
logger.debug('Chroot mode enabled - using selective path mounts for security');
// System paths (read-only) - required for binaries and libraries
agentVolumes.push(
'/usr:/host/usr:ro',
'/bin:/host/bin:ro',
'/sbin:/host/sbin:ro',
);
// Handle /lib and /lib64 - may be symlinks on some systems
// Always mount them to ensure library resolution works
agentVolumes.push('/lib:/host/lib:ro');
agentVolumes.push('/lib64:/host/lib64:ro');
// Tool cache - language runtimes from GitHub runners (read-only)
// /opt/hostedtoolcache contains Python, Node, Ruby, Go, Java, etc.
agentVolumes.push('/opt:/host/opt:ro');
// Special filesystem mounts for chroot (needed for devices and runtime introspection)
// NOTE: Only /proc/self is mounted (not full /proc) to prevent exposure of other
// processes' environment variables while still allowing binaries like Go to find themselves
agentVolumes.push(
'/proc/self:/host/proc/self:ro', // Process self-info only (needed by Go to find GOROOT)
'/sys:/host/sys:ro', // Read-only sysfs
'/dev:/host/dev:ro', // Read-only device nodes (needed by some runtimes)
);
// User home directory for project files and Rust/Cargo (read-write)
// Note: $HOME is already mounted at the container level, this adds it under /host
// Use getRealUserHome() to get the actual user's home (not /root when running with sudo)
const userHome = getRealUserHome();
agentVolumes.push(`${userHome}:/host${userHome}:rw`);
// /tmp is needed for chroot mode to write temporary command scripts
// The entrypoint.sh writes to /host/tmp/awf-cmd-$$.sh
agentVolumes.push('/tmp:/host/tmp:rw');
// Minimal /etc - only what's needed for runtime
// Note: /etc/shadow is NOT mounted (contains password hashes)
agentVolumes.push(
'/etc/ssl:/host/etc/ssl:ro', // SSL certificates
'/etc/ca-certificates:/host/etc/ca-certificates:ro', // CA certificates
'/etc/alternatives:/host/etc/alternatives:ro', // For update-alternatives (runtime version switching)
'/etc/ld.so.cache:/host/etc/ld.so.cache:ro', // Dynamic linker cache
'/etc/passwd:/host/etc/passwd:ro', // User database (needed for getent/user lookup)
'/etc/group:/host/etc/group:ro', // Group database (needed for getent/group lookup)
'/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro', // Name service switch config
);
// SECURITY: Hide Docker socket to prevent firewall bypass via 'docker run'
// An attacker could otherwise spawn a new container without network restrictions
agentVolumes.push('/dev/null:/host/var/run/docker.sock:ro');
// Also hide /run/docker.sock (symlink on some systems)
agentVolumes.push('/dev/null:/host/run/docker.sock:ro');
logger.debug('Selective mounts configured: system paths (ro), home (rw), Docker socket hidden');
}
// Add SSL CA certificate mount if SSL Bump is enabled
// This allows the agent container to trust the dynamically-generated CA
if (sslConfig) {
agentVolumes.push(`${sslConfig.caFiles.certPath}:/usr/local/share/ca-certificates/awf-ca.crt:ro`);
// Set environment variable to indicate SSL Bump is enabled
environment.AWF_SSL_BUMP_ENABLED = 'true';
}
// Add custom volume mounts if specified
if (config.volumeMounts && config.volumeMounts.length > 0) {
logger.debug(`Adding ${config.volumeMounts.length} custom volume mount(s)`);
config.volumeMounts.forEach(mount => {
agentVolumes.push(mount);
});
} else if (!config.enableChroot) {
// If no custom mounts specified AND not using chroot mode,
// include blanket host filesystem mount for backward compatibility
logger.debug('No custom mounts specified, using blanket /:/host:rw mount');
agentVolumes.unshift('/:/host:rw');
}
// Agent service configuration
const agentService: any = {
container_name: 'awf-agent',
networks: {
'awf-net': {
ipv4_address: networkConfig.agentIp,
},
},
dns: dnsServers, // Use configured DNS servers (prevents DNS exfiltration)
dns_search: [], // Disable DNS search domains to prevent embedded DNS fallback
volumes: agentVolumes,
environment,
depends_on: {
'squid-proxy': {
condition: 'service_healthy',
},
},
// NET_ADMIN is required for iptables setup in entrypoint.sh.
// SYS_CHROOT is added when --enable-chroot is specified for chroot operations.
// Security: Both capabilities are dropped before running user commands
// via 'capsh --drop=cap_net_admin,cap_sys_chroot' in containers/agent/entrypoint.sh.
// This prevents malicious code from modifying iptables rules or using chroot.
cap_add: config.enableChroot ? ['NET_ADMIN', 'SYS_CHROOT'] : ['NET_ADMIN'],
// 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 and no-new-privileges to restrict dangerous syscalls and prevent privilege escalation
security_opt: [
'no-new-privileges:true',
`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
command: ['/bin/bash', '-c', config.agentCommand.replace(/\$/g, '$$$$')],
};
// Set working directory if specified (overrides Dockerfile WORKDIR)
if (config.containerWorkDir) {
agentService.working_dir = config.containerWorkDir;
logger.debug(`Set container working directory to: ${config.containerWorkDir}`);
}
// Enable host.docker.internal for agent when --enable-host-access is set
if (config.enableHostAccess) {
agentService.extra_hosts = ['host.docker.internal:host-gateway'];
}
// Use GHCR image or build locally
// Priority: GHCR preset images > local build (when requested) > custom images
// For presets ('default', 'act'), use GHCR images (even in chroot mode)
// This fixes a bug where --enable-chroot would ignore --agent-image preset
const agentImage = config.agentImage || 'default';
const isPreset = agentImage === 'default' || agentImage === 'act';
if (useGHCR && isPreset) {
// Use pre-built GHCR image for preset images (works in both normal and chroot mode)
// The GHCR images already have the necessary setup for chroot mode
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
agentService.image = `${registry}/${imageName}:${tag}`;
if (config.enableChroot) {
logger.debug(`Chroot mode: using GHCR image ${imageName}:${tag}`);
}
} else if (config.buildLocal || (config.enableChroot && !isPreset)) {
// Build locally when:
// 1. --build-local is explicitly specified, OR
// 2. --enable-chroot with a custom (non-preset) image
const buildArgs: Record<string, string> = {
USER_UID: getSafeHostUid(),
USER_GID: getSafeHostGid(),
};
// Determine dockerfile based on chroot mode
let dockerfile = 'Dockerfile';
if (config.enableChroot) {
// Chroot mode: use minimal Dockerfile since user commands run on host
dockerfile = 'Dockerfile.minimal';
logger.debug('Chroot mode: building minimal agent image locally');
}
// For custom images (not presets), pass as BASE_IMAGE build arg
// For 'act' preset with --build-local, use the act base image
if (!isPreset) {
buildArgs.BASE_IMAGE = agentImage;
} else if (agentImage === 'act') {
// When building locally with 'act' preset, use the catthehacker act image
buildArgs.BASE_IMAGE = ACT_PRESET_BASE_IMAGE;
}
// For 'default' preset with --build-local, use the Dockerfile's default (ubuntu:22.04)
agentService.build = {
context: path.join(projectRoot, 'containers/agent'),
dockerfile,
args: buildArgs,
};
} else {
// Custom image specified without --build-local
// Use the image directly (user is responsible for ensuring compatibility)
agentService.image = agentImage;
}
return {
services: {
'squid-proxy': squidService,
'agent': agentService,
},
networks: {
'awf-net': {
external: true,
},
},
};
}
/**
* Writes configuration files to disk
* Uses fixed network configuration (172.30.0.0/24) defined in host-iptables.ts
*/
export async function writeConfigs(config: WrapperConfig): Promise<void> {
logger.debug('Writing configuration files...');
// Ensure work directory exists
if (!fs.existsSync(config.workDir)) {
fs.mkdirSync(config.workDir, { recursive: true });
}
// Create agent logs directory for persistence
const agentLogsDir = path.join(config.workDir, 'agent-logs');
if (!fs.existsSync(agentLogsDir)) {
fs.mkdirSync(agentLogsDir, { recursive: true });
}
logger.debug(`Agent logs directory created at: ${agentLogsDir}`);
// Create squid logs directory for persistence
// If proxyLogsDir is specified, write directly there (timeout-safe)
// Otherwise, use workDir/squid-logs (will be moved to /tmp after cleanup)
// Note: Squid runs as user 'proxy' (UID 13, GID 13 in ubuntu/squid image)
// We need to make the directory writable by the proxy user
const squidLogsDir = config.proxyLogsDir || path.join(config.workDir, 'squid-logs');
if (!fs.existsSync(squidLogsDir)) {
fs.mkdirSync(squidLogsDir, { recursive: true, mode: 0o777 });
}
logger.debug(`Squid logs directory created at: ${squidLogsDir}`);
// Use fixed network configuration (network is created by host-iptables.ts)
const networkConfig = {
subnet: '172.30.0.0/24',
squidIp: '172.30.0.10',
agentIp: '172.30.0.20',
};
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);
}
}
// Generate SSL Bump certificates if enabled
let sslConfig: SslConfig | undefined;
if (config.sslBump) {
logger.info('SSL Bump enabled - generating per-session CA certificate...');
try {
const caFiles = await generateSessionCa({ workDir: config.workDir });
const sslDbPath = await initSslDb(config.workDir);
sslConfig = { caFiles, sslDbPath };
logger.info('SSL Bump CA certificate generated successfully');
logger.warn('⚠️ SSL Bump mode: HTTPS traffic will be intercepted for URL inspection');
logger.warn(' A per-session CA certificate has been generated (valid for 1 day)');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Failed to generate SSL Bump CA: ${message}`);
throw new Error(`SSL Bump initialization failed: ${message}`);
}
}
// Transform user URL patterns to regex patterns for Squid ACLs
let urlPatterns: string[] | undefined;
if (config.allowedUrls && config.allowedUrls.length > 0) {
urlPatterns = parseUrlPatterns(config.allowedUrls);
logger.debug(`Parsed ${urlPatterns.length} URL pattern(s) for SSL Bump filtering`);
}
// Write Squid config
// Note: Use container path for SSL database since it's mounted at /var/spool/squid_ssl_db
const squidConfig = generateSquidConfig({
domains: config.allowedDomains,
blockedDomains: config.blockedDomains,
port: SQUID_PORT,
interceptPort: SQUID_INTERCEPT_PORT,
sslBump: config.sslBump,
caFiles: sslConfig?.caFiles,
sslDbPath: sslConfig ? '/var/spool/squid_ssl_db' : undefined,
urlPatterns,
enableHostAccess: config.enableHostAccess,
allowHostPorts: config.allowHostPorts,
});
const squidConfigPath = path.join(config.workDir, 'squid.conf');
fs.writeFileSync(squidConfigPath, squidConfig);
logger.debug(`Squid config written to: ${squidConfigPath}`);
// Write Docker Compose config
const dockerCompose = generateDockerCompose(config, networkConfig, sslConfig);
const dockerComposePath = path.join(config.workDir, 'docker-compose.yml');
fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose));
logger.debug(`Docker Compose config written to: ${dockerComposePath}`);
}
/**
* Checks Squid logs for access denials to provide better error context
* @param workDir - Working directory containing configs
* @param proxyLogsDir - Optional custom directory where proxy logs are written
*/
async function checkSquidLogs(workDir: string, proxyLogsDir?: string): Promise<{ hasDenials: boolean; blockedTargets: BlockedTarget[] }> {
try {
// Read from the access.log file (Squid doesn't write access logs to stdout)
// If proxyLogsDir is specified, logs are written directly there
const squidLogsDir = proxyLogsDir || path.join(workDir, 'squid-logs');
const accessLogPath = path.join(squidLogsDir, 'access.log');
let logContent = '';
if (fs.existsSync(accessLogPath)) {
logContent = fs.readFileSync(accessLogPath, 'utf-8');
} else {
logger.debug(`Squid access log not found at: ${accessLogPath}`);
return { hasDenials: false, blockedTargets: [] };
}
const blockedTargets: BlockedTarget[] = [];
const seenTargets = new Set<string>();
const lines = logContent.split('\n');
for (const line of lines) {
// Look for TCP_DENIED entries in Squid logs
// Format: timestamp IP domain:port dest:port version method status TCP_DENIED:HIER_NONE domain:port "user-agent"
if (line.includes('TCP_DENIED')) {
// Extract the domain:port which appears after the method
// Example: "1760994429.358 172.30.0.20:36274 github.com:8443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE github.com:8443 "curl/7.81.0""
const match = line.match(/(?:GET|POST|CONNECT|PUT|DELETE|HEAD)\s+\d+\s+TCP_DENIED:\S+\s+([^\s]+)/);
if (match && match[1]) {
const target = match[1]; // Full target with port (e.g., "github.com:8443")
if (!seenTargets.has(target)) {
seenTargets.add(target);
// Parse domain and port
const colonIndex = target.lastIndexOf(':');
let domain: string;
let port: string | undefined;
if (colonIndex !== -1) {
domain = target.substring(0, colonIndex);
port = target.substring(colonIndex + 1);
// Validate that port is actually a number (to handle IPv6 addresses correctly)
if (!/^\d+$/.test(port)) {
domain = target;
port = undefined;
}
} else {
domain = target;
}
blockedTargets.push({ target, domain, port });
}
}
}
}
return { hasDenials: blockedTargets.length > 0, blockedTargets };
} catch (error) {
logger.debug('Could not check Squid logs:', error);
return { hasDenials: false, blockedTargets: [] };
}
}
/**
* Starts Docker Compose services
* @param workDir - Working directory containing Docker Compose config
* @param allowedDomains - List of allowed domains for error reporting
* @param proxyLogsDir - Optional custom directory for proxy logs
* @param skipPull - If true, use local images without pulling from registry
*/
export async function startContainers(workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean): Promise<void> {
logger.info('Starting containers...');
// Force remove any existing containers with these names to avoid conflicts
// This handles orphaned containers from failed/interrupted previous runs
logger.debug('Removing any existing containers with conflicting names...');
try {
await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent'], {
reject: false,
});
} catch {
// Ignore errors if containers don't exist
logger.debug('No existing containers to remove (this is normal)');
}
try {
const composeArgs = ['compose', 'up', '-d'];
if (skipPull) {
composeArgs.push('--pull', 'never');
logger.debug('Using --pull never (skip-pull mode)');
}
await execa('docker', composeArgs, {
cwd: workDir,
stdio: 'inherit',
});
logger.success('Containers started successfully');
} catch (error) {
// Check if this is a healthcheck failure
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg.includes('is unhealthy') || errorMsg.includes('dependency failed')) {
// Check Squid logs to see if it's actually working and blocking traffic
const { hasDenials, blockedTargets } = await checkSquidLogs(workDir, proxyLogsDir);
if (hasDenials) {
logger.error('Firewall blocked domains during startup:');
const missingDomains: string[] = [];
const portIssues: BlockedTarget[] = [];
blockedTargets.forEach(blocked => {
const isAllowed = allowedDomains.some(allowed =>
blocked.domain === allowed || blocked.domain.endsWith('.' + allowed)
);
if (!isAllowed) {
// Domain not in allowlist
logger.error(` - Blocked: ${blocked.target} (domain not in allowlist)`);
missingDomains.push(blocked.domain);
} else if (blocked.port && blocked.port !== '80' && blocked.port !== '443') {
// Domain is allowed but port is not
logger.error(` - Blocked: ${blocked.target} (port ${blocked.port} not allowed, only 80 and 443 are permitted)`);
portIssues.push(blocked);
} else {
// Other reason (shouldn't happen often)
logger.error(` - Blocked: ${blocked.target}`);
}
});
logger.error('Allowed domains:');
allowedDomains.forEach(domain => {
logger.error(` - Allowed: ${domain}`);
});
if (missingDomains.length > 0) {
logger.error(`To fix domain issues: --allow-domains "${[...allowedDomains, ...missingDomains].join(',')}"`);
}
if (portIssues.length > 0) {
logger.error('To fix port issues: Use standard ports 80 (HTTP) or 443 (HTTPS)');
}
// Create a more user-friendly error
const blockedList = blockedTargets.map(b => `"${b.target}"`).join(', ');
throw new Error(
`Firewall blocked access to: ${blockedList}. ` +
`Check error messages above for details.`
);
}
}
logger.error('Failed to start containers:', error);
throw error;
}
}
/**
* Runs the agent command in the container and reports any blocked domains
*/
export async function runAgentCommand(workDir: string, allowedDomains: string[], proxyLogsDir?: string): Promise<{ exitCode: number; blockedDomains: string[] }> {
logger.info('Executing agent command...');
try {
// Stream logs in real-time using docker logs -f (follow mode)
// Run this in the background and wait for the container to exit separately
const logsProcess = execa('docker', ['logs', '-f', 'awf-agent'], {
stdio: 'inherit',
reject: false,
});
// Wait for the container to exit (this will run concurrently with log streaming)
const { stdout: exitCodeStr } = await execa('docker', [
'wait',
'awf-agent',
]);
const exitCode = parseInt(exitCodeStr.trim(), 10);
// Wait for the logs process to finish (it should exit automatically when container stops)
await logsProcess;
logger.debug(`Agent exit code: ${exitCode}`);
// Small delay to ensure Squid logs are flushed to disk
await new Promise(resolve => setTimeout(resolve, 500));
// Check Squid logs to see if any domains were blocked (do this BEFORE cleanup)
const { hasDenials, blockedTargets } = await checkSquidLogs(workDir, proxyLogsDir);
// If command failed (non-zero exit) and domains were blocked, show a warning
if (exitCode !== 0 && hasDenials) {
logger.warn('Firewall blocked domains:');
const missingDomains: string[] = [];
const portIssues: BlockedTarget[] = [];
blockedTargets.forEach(blocked => {
const isAllowed = allowedDomains.some(allowed =>
blocked.domain === allowed || blocked.domain.endsWith('.' + allowed)
);
if (!isAllowed) {
// Domain not in allowlist
logger.warn(` - Blocked: ${blocked.target} (domain not in allowlist)`);
missingDomains.push(blocked.domain);
} else if (blocked.port && blocked.port !== '80' && blocked.port !== '443') {
// Domain is allowed but port is not
logger.warn(` - Blocked: ${blocked.target} (port ${blocked.port} not allowed, only 80 and 443 are permitted)`);
portIssues.push(blocked);
} else {
// Other reason (shouldn't happen often)
logger.warn(` - Blocked: ${blocked.target}`);
}
});
logger.warn('Allowed domains:');
allowedDomains.forEach(domain => {
logger.warn(` - Allowed: ${domain}`);
});
if (missingDomains.length > 0) {
logger.warn(`To fix domain issues: --allow-domains "${[...allowedDomains, ...missingDomains].join(',')}"`);
}
if (portIssues.length > 0) {
logger.warn('To fix port issues: Use standard ports 80 (HTTP) or 443 (HTTPS)');
}
}
return { exitCode, blockedDomains: blockedTargets.map(b => b.domain) };
} catch (error) {
logger.error('Failed to run agent command:', error);
throw error;
}
}
/**
* Stops and removes Docker Compose services
*/
export async function stopContainers(workDir: string, keepContainers: boolean): Promise<void> {
if (keepContainers) {
logger.info('Keeping containers running (--keep-containers enabled)');
return;
}
logger.info('Stopping containers...');
try {
await execa('docker', ['compose', 'down', '-v'], {
cwd: workDir,
stdio: 'inherit',
});
logger.success('Containers stopped successfully');
} catch (error) {
logger.error('Failed to stop containers:', error);
throw error;
}
}
/**
* Cleans up temporary files
* Preserves agent logs by moving them to a persistent location before cleanup
* @param workDir - Working directory containing configs and logs
* @param keepFiles - If true, skip cleanup and keep files
* @param proxyLogsDir - Optional custom directory where Squid proxy logs were written directly