Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,11 @@
"description": "Security and isolation configuration.",
"additionalProperties": false,
"properties": {
"securityMode": {
"type": "string",
"enum": ["strict", "compat"],
"description": "Security enforcement mode. 'strict' (default) enforces network-isolation, API proxy credential injection, and rejects host-access/DinD. 'compat' preserves legacy iptables-based mode (requires sudo)."
},
"sslBump": {
"type": "boolean",
"description": "Enable SSL bumping (TLS interception) in the Squid proxy. Requires a custom CA certificate."
Expand Down
5 changes: 5 additions & 0 deletions src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,11 @@
"description": "Security and isolation configuration.",
"additionalProperties": false,
"properties": {
"securityMode": {
"type": "string",
"enum": ["strict", "compat"],
"description": "Security enforcement mode. 'strict' (default) enforces network-isolation, API proxy credential injection, and rejects host-access/DinD. 'compat' preserves legacy iptables-based mode (requires sudo)."
},
"sslBump": {
"type": "boolean",
"description": "Enable SSL bumping (TLS interception) in the Squid proxy. Requires a custom CA certificate."
Expand Down
7 changes: 7 additions & 0 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,13 @@ program
' WARNING: allows firewall bypass via docker run',
false
)
.option(
'--security-mode <mode>',
'Security enforcement mode (default: strict).\n' +
' strict: network-isolation + api-proxy, no sudo/iptables.\n' +
' compat: legacy iptables mode, requires sudo.',
'strict'
)
.option(
'--enable-dlp',
'Enable DLP (Data Loss Prevention) scanning to block credential\n' +
Expand Down
1 change: 1 addition & 0 deletions src/commands/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
sslBump: options.sslBump as boolean,
enableDind: options.enableDind as boolean,
enableDlp: options.enableDlp as boolean,
securityMode: options.securityMode as 'strict' | 'compat' | undefined,
allowedUrls,
enableApiProxy: options.enableApiProxy as boolean,
modelFallback:
Expand Down
14 changes: 2 additions & 12 deletions src/commands/validators/config-assembly-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,6 @@ describe('config-assembly', () => {
});

describe('network-isolation validation', () => {
it('should warn that network-isolation is experimental', () => {
mockBuildConfigOnce({ networkIsolation: true });

callAssembleWith();

expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('--network-isolation is experimental'),
);
});

it('should exit if --network-isolation is combined with --dns-over-https', () => {
mockBuildConfigOnce({ networkIsolation: true, dnsOverHttps: true });

Expand All @@ -47,7 +37,7 @@ describe('config-assembly', () => {
}).toThrow('process.exit(1)');

expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('--network-isolation is not yet supported with --dns-over-https'),
expect.stringContaining('--network-isolation is not supported with --dns-over-https'),
);
});

Expand All @@ -59,7 +49,7 @@ describe('config-assembly', () => {
}).toThrow('process.exit(1)');

expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('--network-isolation is not yet supported with --enable-host-access'),
expect.stringContaining('--network-isolation is not supported with --enable-host-access'),
);
});

Expand Down
2 changes: 2 additions & 0 deletions src/commands/validators/config-assembly.test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ jest.mock('../build-config', () => ({
logLevel: args.logLevel,
allowedDomains: args.allowedDomains,
blockedDomains: args.blockedDomains,
securityMode: 'compat',
enableApiProxy: false,
enableTokenSteering: false,
envAll: false,
Expand Down Expand Up @@ -149,6 +150,7 @@ export const createBuildConfigResult = (
logLevel: 'info',
allowedDomains: ['example.com'],
blockedDomains: [],
securityMode: 'compat',
enableApiProxy: false,
enableTokenSteering: false,
envAll: false,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/validators/config-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LogAndLimitsResult } from './log-and-limits';
import { NetworkOptionsResult } from './network-options';
import { AgentOptionsResult } from './agent-options';
import { validateInfrastructureOptions, applyRateLimitConfig, validateFeatureFlagCompatibility } from './infrastructure-validator';
import { applySecurityMode } from './security-mode';
import { validateHostAccessConfig } from './network-access-validator';
import { validateApiProxyOptions, validateCopilotModelOption } from './api-proxy-validator';

Expand Down Expand Up @@ -64,6 +65,7 @@ export function assembleAndValidateConfig(
});

validateInfrastructureOptions(config);
applySecurityMode(config);
applyAgentTimeout(options.agentTimeout as string | undefined, config, logger);
applyRateLimitConfig(config, options);
validateFeatureFlagCompatibility(config);
Expand Down
7 changes: 3 additions & 4 deletions src/commands/validators/infrastructure-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,20 @@ export function validateFeatureFlagCompatibility(config: WrapperConfig): void {
logger.debug(`Loading environment variables from file: ${config.envFile}`);
}

// Network-isolation (topology) mode: reject combinations that are not yet
// Network-isolation (topology) mode: reject combinations that are not
// supported because they depend on host-iptables or a sidecar that needs
// direct external connectivity bypassing the dual-homed proxy.
if (config.networkIsolation) {
if (config.dnsOverHttps) {
logger.error('❌ --network-isolation is not yet supported with --dns-over-https.');
logger.error('❌ --network-isolation is not supported with --dns-over-https.');
logger.error(' The DoH proxy needs direct external connectivity, which the internal network does not provide.');
process.exit(1);
}
if (config.enableHostAccess) {
logger.error('❌ --network-isolation is not yet supported with --enable-host-access.');
logger.error('❌ --network-isolation is not supported with --enable-host-access.');
logger.error(' Host access relies on host-level iptables, which network-isolation mode does not configure.');
process.exit(1);
}
logger.warn('⚠️ --network-isolation is experimental: egress is enforced via Docker network topology instead of iptables.');
} else if (config.topologyAttach && config.topologyAttach.length > 0) {
logger.error('❌ --topology-attach requires --network-isolation.');
logger.error(' Trusted containers can only be attached to the internal topology network in network-isolation mode.');
Expand Down
138 changes: 138 additions & 0 deletions src/commands/validators/security-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { WrapperConfig } from '../../types';
import { applySecurityMode } from './security-mode';

// Suppress logger output in tests
jest.mock('../../logger', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));

import { logger } from '../../logger';

function makeConfig(overrides: Partial<WrapperConfig> = {}): WrapperConfig {
return {
agentCommand: 'echo test',
logLevel: 'info',
allowedDomains: ['github.com'],
blockedDomains: [],
proxyLogsDir: '/tmp/logs',
dnsServers: ['8.8.8.8'],
enableHostAccess: false,
networkIsolation: false,
enableApiProxy: false,
enableDind: false,
sslBump: false,
enableDlp: false,
envAll: false,
buildLocal: false,
skipPull: false,
keepContainers: false,
imageRegistry: 'ghcr.io/github/gh-aw-firewall',
imageTag: 'latest',
localhostDetected: false,
...overrides,
} as WrapperConfig;
}

describe('applySecurityMode', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('strict mode (default)', () => {
it('should force networkIsolation on', () => {
const config = makeConfig({ securityMode: 'strict', networkIsolation: false });
applySecurityMode(config);
expect(config.networkIsolation).toBe(true);
});

it('should force enableApiProxy on', () => {
const config = makeConfig({ securityMode: 'strict', enableApiProxy: false });
applySecurityMode(config);
expect(config.enableApiProxy).toBe(true);
});

it('should be the default when securityMode is undefined', () => {
const config = makeConfig({ securityMode: undefined, networkIsolation: false, enableApiProxy: false });
applySecurityMode(config);
expect(config.networkIsolation).toBe(true);
expect(config.enableApiProxy).toBe(true);
});

it('should override enableHostAccess with warning', () => {
const config = makeConfig({ enableHostAccess: true });
applySecurityMode(config);
expect(config.enableHostAccess).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('--enable-host-access was ignored'),
);
});

it('should override enableDind with warning', () => {
const config = makeConfig({ enableDind: true });
applySecurityMode(config);
expect(config.enableDind).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('--enable-dind was ignored'),
);
});

it('should override dnsOverHttps with warning', () => {
const config = makeConfig({ dnsOverHttps: 'https://dns.google/dns-query' });
applySecurityMode(config);
expect(config.dnsOverHttps).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('--dns-over-https was ignored'),
);
});

it('should warn that --security-mode compat is required for overridden options', () => {
const config = makeConfig({ enableHostAccess: true, enableDind: true });
applySecurityMode(config);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('--security-mode compat'),
);
});

it('should not warn when compatible options are already set', () => {
const config = makeConfig({
securityMode: 'strict',
networkIsolation: true,
enableApiProxy: true,
enableHostAccess: false,
enableDind: false,
});
applySecurityMode(config);
expect(logger.warn).not.toHaveBeenCalled();
});
});

describe('compat mode', () => {
it('should not modify any config values', () => {
const config = makeConfig({
securityMode: 'compat',
networkIsolation: false,
enableApiProxy: false,
enableHostAccess: true,
enableDind: true,
});
applySecurityMode(config);
expect(config.networkIsolation).toBe(false);
expect(config.enableApiProxy).toBe(false);
expect(config.enableHostAccess).toBe(true);
expect(config.enableDind).toBe(true);
});

it('should log info about compat mode', () => {
const config = makeConfig({ securityMode: 'compat' });
applySecurityMode(config);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('compat security mode'),
);
});
});
});
74 changes: 74 additions & 0 deletions src/commands/validators/security-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { WrapperConfig } from '../../types';
import { logger } from '../../logger';

/**
* Applies security-mode enforcement to the assembled config.
*
* In strict mode (the default), incompatible options are overridden with
* warnings and bundled defaults (network-isolation, api-proxy) are forced on.
*
* In compat mode, the legacy iptables-based configuration is preserved and
* no overrides are applied.
*
* Must be called **after** `buildConfig()` assembles the raw config from CLI
* options and config file, but **before** the downstream validators that
* check for mutual exclusions (since strict mode resolves those conflicts).
*/
export function applySecurityMode(config: WrapperConfig): void {
const mode = config.securityMode ?? 'strict';

if (mode === 'compat') {
logger.info('Running in compat security mode (legacy iptables-based enforcement).');
return;
}

// --- strict mode (default) ---

// Force network-isolation on
if (!config.networkIsolation) {
if (config.networkIsolation === false) {
// Explicitly set to false via CLI or config — warn and override
logger.warn(
'⚠️ network.isolation: false was ignored (incompatible with --security-mode strict, the default).\n' +
' Pass --security-mode compat to disable network isolation.',
);
}
config.networkIsolation = true;
}

// Force api-proxy on
if (!config.enableApiProxy) {
if (config.enableApiProxy === false) {
logger.warn(
'⚠️ --enable-api-proxy: false was ignored (incompatible with --security-mode strict, the default).\n' +
' Pass --security-mode compat to disable the API proxy.',
);
}
config.enableApiProxy = true;
}

// Override incompatible options
if (config.enableHostAccess) {
logger.warn(
'⚠️ --enable-host-access was ignored (incompatible with --security-mode strict, the default).\n' +
' Pass --security-mode compat to enable host access.',
);
config.enableHostAccess = false;
Comment on lines +59 to +64
}

if (config.enableDind) {
logger.warn(
'⚠️ --enable-dind was ignored (incompatible with --security-mode strict, the default).\n' +
' Pass --security-mode compat to enable Docker-in-Docker.',
);
config.enableDind = false;
}

if (config.dnsOverHttps) {
logger.warn(
'⚠️ --dns-over-https was ignored (incompatible with --security-mode strict, the default).\n' +
' Pass --security-mode compat to use DNS-over-HTTPS.',
);
config.dnsOverHttps = undefined;
}
}
1 change: 1 addition & 0 deletions src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface AwfFileConfig {
};
};
security?: {
securityMode?: 'strict' | 'compat';
sslBump?: boolean;
enableDlp?: boolean;
enableHostAccess?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/config-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
anthropicTokenUrl: config.apiProxy?.auth?.anthropicTokenUrl,

sslBump: config.security?.sslBump,
securityMode: config.security?.securityMode,
enableDlp: config.security?.enableDlp,
enableHostAccess: config.security?.enableHostAccess,
allowHostPorts: joinPorts(config.security?.allowHostPorts),
Expand Down
14 changes: 14 additions & 0 deletions src/types/security-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,18 @@ export interface SecurityOptions {
* @default false
*/
enableDlp?: boolean;

/**
* Security mode that bundles related security settings.
*
* - `'strict'` (default): Enforces network-isolation (Docker network topology),
* API proxy (credential injection), and rejects host-access / DinD passthrough.
* Incompatible options passed via CLI or config are overridden with warnings.
*
* - `'compat'`: Preserves the legacy iptables-based configuration. Allows
* host-access, DinD, and direct credential passing. Requires sudo/NET_ADMIN.
*
* @default 'strict'
*/
securityMode?: 'strict' | 'compat';
}
Loading