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
29 changes: 29 additions & 0 deletions containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,20 @@ if [ -n "$AWF_API_PROXY_IP" ]; then
iptables -A OUTPUT -p tcp -d "$AWF_API_PROXY_IP" -j ACCEPT
fi

# Log dangerous port access attempts for audit (rate-limited to avoid log flooding)
# These ports are blocked by NAT RETURN + final DROP, but logging helps identify
# what the agent tried to access
echo "[iptables] Adding audit LOG rules for dangerous ports and default deny..."
iptables -A OUTPUT -p tcp -m multiport --dports 22,23,25,110,143,445,1433,1521,3306,3389,5432,6379,27017,27018,28017 \
Comment thread
Mossaka marked this conversation as resolved.
Outdated
-m limit --limit 5/min --limit-burst 10 -j LOG --log-prefix "[FW_BLOCKED_DANGEROUS_PORT] " --log-level 4 --log-uid

# Drop all other TCP and UDP traffic (default deny policy)
# TCP: ensures only explicitly allowed ports can be accessed
# UDP: prevents DNS exfiltration by blocking direct queries to non-configured DNS servers
echo "[iptables] Drop all non-allowed TCP and UDP traffic (default deny)..."
iptables -A OUTPUT -p tcp -m limit --limit 10/min --limit-burst 20 -j LOG --log-prefix "[FW_BLOCKED_TCP] " --log-level 4 --log-uid
iptables -A OUTPUT -p tcp -j DROP
iptables -A OUTPUT -p udp -m limit --limit 10/min --limit-burst 20 -j LOG --log-prefix "[FW_BLOCKED_UDP_AGENT] " --log-level 4 --log-uid
iptables -A OUTPUT -p udp -j DROP

echo "[iptables] NAT rules applied successfully"
Expand All @@ -328,3 +337,23 @@ if [ "$IP6TABLES_AVAILABLE" = true ]; then
else
echo "[iptables] (ip6tables NAT not available)"
fi

# Dump full iptables state for audit trail
# Written to the init signal volume so it can be preserved by the host
AUDIT_FILE="/tmp/awf-init/iptables-audit.txt"
echo "# iptables audit dump - $(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$AUDIT_FILE"
echo "" >> "$AUDIT_FILE"
echo "## IPv4 NAT rules" >> "$AUDIT_FILE"
iptables-save -t nat >> "$AUDIT_FILE" 2>/dev/null || echo "(iptables-save not available)" >> "$AUDIT_FILE"
echo "" >> "$AUDIT_FILE"
echo "## IPv4 filter rules" >> "$AUDIT_FILE"
iptables-save -t filter >> "$AUDIT_FILE" 2>/dev/null || echo "(iptables-save not available)" >> "$AUDIT_FILE"
if [ "$IP6TABLES_AVAILABLE" = true ]; then
echo "" >> "$AUDIT_FILE"
echo "## IPv6 NAT rules" >> "$AUDIT_FILE"
ip6tables-save -t nat >> "$AUDIT_FILE" 2>/dev/null || true
echo "" >> "$AUDIT_FILE"
echo "## IPv6 filter rules" >> "$AUDIT_FILE"
ip6tables-save -t filter >> "$AUDIT_FILE" 2>/dev/null || true
fi
echo "[iptables] Audit state dumped to $AUDIT_FILE"
42 changes: 41 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
runAgentCommand,
stopContainers,
cleanup,
preserveIptablesAudit,
} from './docker-manager';
import {
ensureFirewallNetwork,
Expand Down Expand Up @@ -1323,6 +1324,10 @@ program
'--proxy-logs-dir <path>',
'Directory to save Squid proxy access.log'
)
.option(
'--audit-dir <path>',
'Directory for firewall audit artifacts (configs, policy manifest, iptables state)'
)
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
.action(async (args: string[], options) => {
// Require -- separator for passing command arguments
Expand Down Expand Up @@ -1619,6 +1624,7 @@ program
dnsOverHttps,
memoryLimit: memoryLimit.value,
proxyLogsDir: options.proxyLogsDir,
auditDir: options.auditDir || process.env.AWF_AUDIT_DIR,
enableHostAccess: options.enableHostAccess,
allowHostPorts: options.allowHostPorts,
sslBump: options.sslBump,
Expand Down Expand Up @@ -1737,7 +1743,9 @@ program
logger.info(`Received ${signal}, cleaning up...`);
}

// Copy iptables audit BEFORE stopping containers (volumes are destroyed by `docker compose down -v`)
if (containersStarted) {
preserveIptablesAudit(config.workDir, config.auditDir);
await stopContainers(config.workDir, config.keepContainers);
}

Expand All @@ -1746,7 +1754,7 @@ program
}

if (!config.keepContainers) {
await cleanup(config.workDir, false, config.proxyLogsDir);
await cleanup(config.workDir, false, config.proxyLogsDir, config.auditDir);
// Note: We don't remove the firewall network here since it can be reused
// across multiple runs. Cleanup script will handle removal if needed.
} else {
Expand Down Expand Up @@ -1940,6 +1948,38 @@ logsCmd
});
});

// Logs audit subcommand - show enriched audit with rule matching
logsCmd
.command('audit')
.description('Show firewall audit with policy rule matching (requires policy-manifest.json)')
.option(
'--format <format>',
'Output format: json, markdown, pretty',
'pretty'
)
.option('--source <path>', 'Path to log directory or "running" for live container')
.option('--rule <id>', 'Filter to specific rule ID')
.option('--domain <domain>', 'Filter to specific domain')
.option('--decision <decision>', 'Filter to "allowed" or "denied"')
.action(async (options) => {
const validFormats = ['json', 'markdown', 'pretty'];
validateFormat(options.format, validFormats);

if (options.decision && !['allowed', 'denied'].includes(options.decision)) {
logger.error(`Invalid decision filter: ${options.decision}. Must be "allowed" or "denied".`);
process.exit(1);
}

const { auditCommand } = await import('./commands/logs-audit');
await auditCommand({
format: options.format as 'json' | 'markdown' | 'pretty',
source: options.source,
rule: options.rule,
domain: options.domain,
decision: options.decision,
});
});

// Only parse arguments if this file is run directly (not imported as a module)
if (require.main === module) {
program.parse();
Expand Down
190 changes: 190 additions & 0 deletions src/commands/logs-audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* Command handler for `awf logs audit` subcommand
*
* Enriches firewall logs with policy rule matching when a policy-manifest.json
* is available alongside the log files. Shows which specific rule caused each
* allow/deny decision.
*/

import chalk from 'chalk';
import type { LogStatsFormat, PolicyManifest } from '../types';
import { loadAllLogs } from '../logs/log-aggregator';
import { enrichWithPolicyRules, computeRuleStats, EnrichedLogEntry } from '../logs/audit-enricher';
import {
discoverAndSelectSource,
findPolicyManifestForSource,
} from './logs-command-helpers';
import { logger } from '../logger';

export interface AuditCommandOptions {
format: LogStatsFormat;
source?: string;
/** Filter to specific rule ID */
rule?: string;
/** Filter to specific domain */
domain?: string;
/** Filter to 'allowed' or 'denied' */
decision?: 'allowed' | 'denied';
}

function formatAuditJson(entries: EnrichedLogEntry[]): string {
return entries.map(e => JSON.stringify({
timestamp: e.timestamp,
domain: e.domain,
method: e.method,
status: e.statusCode,
decision: e.isAllowed ? 'allowed' : 'denied',
matchedRule: e.matchedRuleId,
matchReason: e.matchReason,
url: e.url,
})).join('\n');
}
Comment thread
Mossaka marked this conversation as resolved.

function formatAuditMarkdown(entries: EnrichedLogEntry[], manifest: PolicyManifest): string {
const lines: string[] = [];
const ruleStats = computeRuleStats(entries, manifest);

lines.push('## Firewall Audit Report\n');

// Policy summary
lines.push('### Active Policy\n');
lines.push(`- **SSL Bump**: ${manifest.sslBumpEnabled ? 'enabled' : 'disabled'}`);
lines.push(`- **DLP**: ${manifest.dlpEnabled ? 'enabled' : 'disabled'}`);
lines.push(`- **Host Access**: ${manifest.hostAccessEnabled ? 'enabled' : 'disabled'}`);
lines.push(`- **DNS Servers**: ${manifest.dnsServers.join(', ')}`);
lines.push(`- **Dangerous Ports Blocked**: ${manifest.dangerousPorts.length} ports\n`);

// Rule hits table
lines.push('### Rule Evaluation\n');
lines.push('| Rule | Action | Hits | Description |');
lines.push('|------|--------|------|-------------|');
for (const rule of ruleStats) {
const actionIcon = rule.action === 'allow' ? '✅' : '🚫';
const hitsStr = rule.hits > 0 ? `**${rule.hits}**` : '0';
lines.push(`| ${rule.ruleId} | ${actionIcon} ${rule.action} | ${hitsStr} | ${rule.description} |`);
}

// Denied requests detail
const denied = entries.filter(e => !e.isAllowed && e.url !== 'error:transaction-end-before-headers');
if (denied.length > 0) {
lines.push('\n### Denied Requests\n');
lines.push('| Timestamp | Domain | Rule | Reason |');
lines.push('|-----------|--------|------|--------|');
for (const entry of denied.slice(0, 50)) { // Cap at 50
const ts = new Date(entry.timestamp * 1000).toISOString();
lines.push(`| ${ts} | ${entry.domain} | ${entry.matchedRuleId} | ${entry.matchReason} |`);
}
if (denied.length > 50) {
lines.push(`\n_...and ${denied.length - 50} more denied requests_`);
}
}

return lines.join('\n');
}

function formatAuditPretty(entries: EnrichedLogEntry[], manifest: PolicyManifest, colorize: boolean): string {
const c = colorize
? chalk
: (new Proxy({}, { get: () => (s: string) => s }) as typeof chalk);

const lines: string[] = [];
const ruleStats = computeRuleStats(entries, manifest);

lines.push(c.bold('Firewall Audit Report'));
lines.push(c.gray('─'.repeat(60)));
lines.push('');

// Rule hits
lines.push(c.bold('Rule Evaluation:'));
const maxIdLen = Math.max(...ruleStats.map(r => r.ruleId.length));
for (const rule of ruleStats) {
const paddedId = rule.ruleId.padEnd(maxIdLen + 2);
const actionStr = rule.action === 'allow' ? c.green(rule.action) : c.red(rule.action);
const hitsStr = rule.hits > 0 ? c.bold(String(rule.hits)) : c.gray('0');
lines.push(` ${paddedId}${actionStr} ${hitsStr} hits ${c.gray(rule.description)}`);
}

// Denied requests
const denied = entries.filter(e => !e.isAllowed && e.url !== 'error:transaction-end-before-headers');
if (denied.length > 0) {
lines.push('');
lines.push(c.bold(`Denied Requests (${denied.length}):`));
for (const entry of denied.slice(0, 20)) {
const ts = new Date(entry.timestamp * 1000).toISOString().slice(11, 23);
lines.push(` ${c.gray(ts)} ${c.red(entry.domain)} ${c.gray(`→ ${entry.matchedRuleId}`)}`);
}
if (denied.length > 20) {
lines.push(c.gray(` ...and ${denied.length - 20} more`));
}
}

lines.push('');
return lines.join('\n');
}

/**
* Main handler for the `awf logs audit` subcommand
*/
export async function auditCommand(options: AuditCommandOptions): Promise<void> {
const source = await discoverAndSelectSource(options.source, {
format: options.format,
shouldLog: (format) => format !== 'json',
});

// Load raw log entries
const entries = await loadAllLogs(source);

if (entries.length === 0) {
logger.error('No log entries found.');
process.exit(1);
}

// Find policy manifest (uses shared discovery logic)
const manifest = findPolicyManifestForSource(source);

if (!manifest) {
logger.error(
'No policy-manifest.json found. The audit command requires a policy manifest.\n' +
'Ensure you are using a version of awf that generates audit artifacts (--audit-dir).'
);
process.exit(1);
}

// Enrich entries with rule matching
let enriched = enrichWithPolicyRules(entries, manifest);

// Apply filters
if (options.rule) {
enriched = enriched.filter(e => e.matchedRuleId === options.rule);
}
if (options.domain) {
const domainFilter = options.domain.toLowerCase();
enriched = enriched.filter(e => e.domain.toLowerCase().includes(domainFilter));
}
if (options.decision) {
const wantAllowed = options.decision === 'allowed';
enriched = enriched.filter(e => e.isAllowed === wantAllowed);
}

// Filter out benign operational entries
const meaningful = enriched.filter(e => e.url !== 'error:transaction-end-before-headers');

// Format and output
const colorize = !!(process.stdout.isTTY && options.format === 'pretty');
let output: string;

switch (options.format) {
case 'json':
output = formatAuditJson(meaningful);
break;
case 'markdown':
output = formatAuditMarkdown(meaningful, manifest);
break;
case 'pretty':
default:
output = formatAuditPretty(meaningful, manifest, colorize);
break;
}

console.log(output);
}
Loading
Loading