diff --git a/README.md b/README.md index 8dd5c59..11cf99b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,39 @@ Domains automatically match all subdomains: sudo awf --allow-domains github.com "curl https://api.github.com" # ✓ works ``` +### Using a Domains File + +For longer domain lists, use a file (one domain per line): + +```bash +# Create a domains file +cat > allowed-domains.txt << EOF +# GitHub domains +github.com +api.github.com +githubusercontent.com + +# Other services +googleapis.com +arxiv.org +EOF + +# Use the file +sudo awf --allow-domains-file allowed-domains.txt "curl https://api.github.com" + +# Combine both methods +sudo awf \ + --allow-domains example.com \ + --allow-domains-file allowed-domains.txt \ + "curl https://api.github.com" +``` + +**File Format:** +- One domain per line +- Lines starting with `#` are treated as comments +- Empty lines are ignored +- Whitespace is trimmed from each line + Common domain lists: ```bash diff --git a/src/cli.test.ts b/src/cli.test.ts index c0d3064..2c22233 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,7 +1,10 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables } from './cli'; +import { parseEnvironmentVariables, parseDomainsFromFile } from './cli'; import { redactSecrets } from './redact-secrets'; import { parseDomains } from './cli'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; describe('cli', () => { describe('domain parsing', () => { @@ -36,6 +39,98 @@ describe('cli', () => { }); }); + describe('domain parsing from file', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should parse domains from file with one domain per line', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org'); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']); + }); + + it('should filter out empty lines', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, 'github.com\n\napi.github.com\n\n\nnpmjs.org\n'); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']); + }); + + it('should filter out comment lines starting with #', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, '# This is a comment\ngithub.com\n# Another comment\napi.github.com'); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual(['github.com', 'api.github.com']); + }); + + it('should trim whitespace from each line', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, ' github.com \n\tapi.github.com\t\n npmjs.org '); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']); + }); + + it('should handle file with mixed content', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, + '# GitHub domains\ngithub.com\n api.github.com \n\n# NPM registry\nnpmjs.org\n# End of file\n' + ); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']); + }); + + it('should handle empty file', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, ''); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual([]); + }); + + it('should handle file with only comments and whitespace', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, '# Comment 1\n\n \n# Comment 2\n\t\n'); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual([]); + }); + + it('should throw error for non-existent file', () => { + const filePath = path.join(tempDir, 'nonexistent.txt'); + + expect(() => parseDomainsFromFile(filePath)).toThrow(); + }); + + it('should handle file with Windows line endings', () => { + const filePath = path.join(tempDir, 'domains.txt'); + fs.writeFileSync(filePath, 'github.com\r\napi.github.com\r\nnpmjs.org\r\n'); + + const result = parseDomainsFromFile(filePath); + + expect(result).toEqual(['github.com', 'api.github.com', 'npmjs.org']); + }); + }); + describe('environment variable parsing', () => { it('should parse KEY=VALUE format correctly', () => { const envVars = ['GITHUB_TOKEN=abc123', 'API_KEY=xyz789']; diff --git a/src/cli.ts b/src/cli.ts index ca949a0..5010dab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { Command } from 'commander'; import * as path from 'path'; import * as os from 'os'; +import * as fs from 'fs'; import { WrapperConfig, LogLevel } from './types'; import { logger } from './logger'; import { @@ -32,6 +33,20 @@ export function parseDomains(input: string): string[] { .filter(d => d.length > 0); } +/** + * Parses domains from a file, one domain per line + * @param filePath - Path to the file containing domains + * @returns Array of trimmed domain strings with empty lines and comments filtered out + * @throws Error if file cannot be read + */ +export function parseDomainsFromFile(filePath: string): string[] { + const content = fs.readFileSync(filePath, 'utf-8'); + return content + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith('#')); +} + /** * Result of parsing environment variables */ @@ -71,10 +86,14 @@ program .name('awf') .description('Network firewall for agentic workflows with domain whitelisting') .version('0.1.0') - .requiredOption( + .option( '--allow-domains ', 'Comma-separated list of allowed domains (e.g., github.com,api.github.com)' ) + .option( + '--allow-domains-file ', + 'Path to file containing allowed domains (one per line)' + ) .option( '--log-level ', 'Log level: debug, info, warn, error', @@ -127,10 +146,31 @@ program logger.setLevel(logLevel); - const allowedDomains = parseDomains(options.allowDomains); + // Parse domains from both sources + let allowedDomains: string[] = []; + + // Parse from --allow-domains flag if provided + if (options.allowDomains) { + allowedDomains = parseDomains(options.allowDomains); + } + + // Parse from --allow-domains-file if provided + if (options.allowDomainsFile) { + try { + const domainsFromFile = parseDomainsFromFile(options.allowDomainsFile); + allowedDomains = [...allowedDomains, ...domainsFromFile]; + } catch (error) { + logger.error(`Failed to read domains file: ${options.allowDomainsFile}`); + logger.error(String(error)); + process.exit(1); + } + } + + // Remove duplicates + allowedDomains = [...new Set(allowedDomains)]; if (allowedDomains.length === 0) { - logger.error('At least one domain must be specified with --allow-domains'); + logger.error('At least one domain must be specified with --allow-domains or --allow-domains-file'); process.exit(1); }