Skip to content

Commit 23245e6

Browse files
Mossakaclaude
andcommitted
feat: add firewall audit/observability and policy logging
Close auditing gaps flagged by ProdSec security review requiring all agentic network interactions be logged and auditable. Changes: - Add --audit-dir flag (+ AWF_AUDIT_DIR env var) for configurable audit artifact directory - Preserve squid.conf, redacted docker-compose.yml, and policy manifest as audit artifacts after each run - Generate policy-manifest.json describing all firewall rules with evaluation order, enabling deterministic "which rule matched?" analysis - Add structured JSONL audit log (audit.jsonl) alongside existing text access.log for machine-readable per-request logging - Add iptables LOG targets before DROP rules (rate-limited) and capture full iptables-save state for audit trail - Add rule matching enrichment that replays ACL evaluation to attribute each log entry to the specific policy rule that caused allow/deny - Add `awf logs audit` command with --rule, --domain, --decision filters - Enhance `awf logs stats/summary` with per-rule hit counts when manifest is available Addresses: github/agentic-workflows#174 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cd6cbd6 commit 23245e6

14 files changed

Lines changed: 1558 additions & 62 deletions

containers/agent/setup-iptables.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,11 +312,20 @@ if [ -n "$AWF_API_PROXY_IP" ]; then
312312
iptables -A OUTPUT -p tcp -d "$AWF_API_PROXY_IP" -j ACCEPT
313313
fi
314314

315+
# Log dangerous port access attempts for audit (rate-limited to avoid log flooding)
316+
# These ports are blocked by NAT RETURN + final DROP, but logging helps identify
317+
# what the agent tried to access
318+
echo "[iptables] Adding audit LOG rules for dangerous ports and default deny..."
319+
iptables -A OUTPUT -p tcp -m multiport --dports 22,23,25,110,143,445,1433,1521,3306,3389,5432,6379,27017,27018,28017 \
320+
-m limit --limit 5/min --limit-burst 10 -j LOG --log-prefix "[FW_BLOCKED_DANGEROUS_PORT] " --log-level 4 --log-uid
321+
315322
# Drop all other TCP and UDP traffic (default deny policy)
316323
# TCP: ensures only explicitly allowed ports can be accessed
317324
# UDP: prevents DNS exfiltration by blocking direct queries to non-configured DNS servers
318325
echo "[iptables] Drop all non-allowed TCP and UDP traffic (default deny)..."
326+
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
319327
iptables -A OUTPUT -p tcp -j DROP
328+
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
320329
iptables -A OUTPUT -p udp -j DROP
321330

322331
echo "[iptables] NAT rules applied successfully"
@@ -328,3 +337,23 @@ if [ "$IP6TABLES_AVAILABLE" = true ]; then
328337
else
329338
echo "[iptables] (ip6tables NAT not available)"
330339
fi
340+
341+
# Dump full iptables state for audit trail
342+
# Written to the init signal volume so it can be preserved by the host
343+
AUDIT_FILE="/tmp/awf-init/iptables-audit.txt"
344+
echo "# iptables audit dump - $(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$AUDIT_FILE"
345+
echo "" >> "$AUDIT_FILE"
346+
echo "## IPv4 NAT rules" >> "$AUDIT_FILE"
347+
iptables-save -t nat >> "$AUDIT_FILE" 2>/dev/null || echo "(iptables-save not available)" >> "$AUDIT_FILE"
348+
echo "" >> "$AUDIT_FILE"
349+
echo "## IPv4 filter rules" >> "$AUDIT_FILE"
350+
iptables-save -t filter >> "$AUDIT_FILE" 2>/dev/null || echo "(iptables-save not available)" >> "$AUDIT_FILE"
351+
if [ "$IP6TABLES_AVAILABLE" = true ]; then
352+
echo "" >> "$AUDIT_FILE"
353+
echo "## IPv6 NAT rules" >> "$AUDIT_FILE"
354+
ip6tables-save -t nat >> "$AUDIT_FILE" 2>/dev/null || true
355+
echo "" >> "$AUDIT_FILE"
356+
echo "## IPv6 filter rules" >> "$AUDIT_FILE"
357+
ip6tables-save -t filter >> "$AUDIT_FILE" 2>/dev/null || true
358+
fi
359+
echo "[iptables] Audit state dumped to $AUDIT_FILE"

src/cli.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
runAgentCommand,
1414
stopContainers,
1515
cleanup,
16+
preserveIptablesAudit,
1617
} from './docker-manager';
1718
import {
1819
ensureFirewallNetwork,
@@ -1323,6 +1324,10 @@ program
13231324
'--proxy-logs-dir <path>',
13241325
'Directory to save Squid proxy access.log'
13251326
)
1327+
.option(
1328+
'--audit-dir <path>',
1329+
'Directory for firewall audit artifacts (configs, policy manifest, iptables state)'
1330+
)
13261331
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
13271332
.action(async (args: string[], options) => {
13281333
// Require -- separator for passing command arguments
@@ -1619,6 +1624,7 @@ program
16191624
dnsOverHttps,
16201625
memoryLimit: memoryLimit.value,
16211626
proxyLogsDir: options.proxyLogsDir,
1627+
auditDir: options.auditDir || process.env.AWF_AUDIT_DIR,
16221628
enableHostAccess: options.enableHostAccess,
16231629
allowHostPorts: options.allowHostPorts,
16241630
sslBump: options.sslBump,
@@ -1737,7 +1743,9 @@ program
17371743
logger.info(`Received ${signal}, cleaning up...`);
17381744
}
17391745

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

@@ -1746,7 +1754,7 @@ program
17461754
}
17471755

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

1951+
// Logs audit subcommand - show enriched audit with rule matching
1952+
logsCmd
1953+
.command('audit')
1954+
.description('Show firewall audit with policy rule matching (requires policy-manifest.json)')
1955+
.option(
1956+
'--format <format>',
1957+
'Output format: json, markdown, pretty',
1958+
'pretty'
1959+
)
1960+
.option('--source <path>', 'Path to log directory or "running" for live container')
1961+
.option('--rule <id>', 'Filter to specific rule ID')
1962+
.option('--domain <domain>', 'Filter to specific domain')
1963+
.option('--decision <decision>', 'Filter to "allowed" or "denied"')
1964+
.action(async (options) => {
1965+
const validFormats = ['json', 'markdown', 'pretty'];
1966+
validateFormat(options.format, validFormats);
1967+
1968+
if (options.decision && !['allowed', 'denied'].includes(options.decision)) {
1969+
logger.error(`Invalid decision filter: ${options.decision}. Must be "allowed" or "denied".`);
1970+
process.exit(1);
1971+
}
1972+
1973+
const { auditCommand } = await import('./commands/logs-audit');
1974+
await auditCommand({
1975+
format: options.format as 'json' | 'markdown' | 'pretty',
1976+
source: options.source,
1977+
rule: options.rule,
1978+
domain: options.domain,
1979+
decision: options.decision,
1980+
});
1981+
});
1982+
19431983
// Only parse arguments if this file is run directly (not imported as a module)
19441984
if (require.main === module) {
19451985
program.parse();

src/commands/logs-audit.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Command handler for `awf logs audit` subcommand
3+
*
4+
* Enriches firewall logs with policy rule matching when a policy-manifest.json
5+
* is available alongside the log files. Shows which specific rule caused each
6+
* allow/deny decision.
7+
*/
8+
9+
import chalk from 'chalk';
10+
import type { LogStatsFormat, PolicyManifest } from '../types';
11+
import { loadAllLogs } from '../logs/log-aggregator';
12+
import { enrichWithPolicyRules, computeRuleStats, EnrichedLogEntry } from '../logs/audit-enricher';
13+
import {
14+
discoverAndSelectSource,
15+
findPolicyManifestForSource,
16+
} from './logs-command-helpers';
17+
import { logger } from '../logger';
18+
19+
export interface AuditCommandOptions {
20+
format: LogStatsFormat;
21+
source?: string;
22+
/** Filter to specific rule ID */
23+
rule?: string;
24+
/** Filter to specific domain */
25+
domain?: string;
26+
/** Filter to 'allowed' or 'denied' */
27+
decision?: 'allowed' | 'denied';
28+
}
29+
30+
function formatAuditJson(entries: EnrichedLogEntry[]): string {
31+
return entries.map(e => JSON.stringify({
32+
timestamp: e.timestamp,
33+
domain: e.domain,
34+
method: e.method,
35+
status: e.statusCode,
36+
decision: e.isAllowed ? 'allowed' : 'denied',
37+
matchedRule: e.matchedRuleId,
38+
matchReason: e.matchReason,
39+
url: e.url,
40+
})).join('\n');
41+
}
42+
43+
function formatAuditMarkdown(entries: EnrichedLogEntry[], manifest: PolicyManifest): string {
44+
const lines: string[] = [];
45+
const ruleStats = computeRuleStats(entries, manifest);
46+
47+
lines.push('## Firewall Audit Report\n');
48+
49+
// Policy summary
50+
lines.push('### Active Policy\n');
51+
lines.push(`- **SSL Bump**: ${manifest.sslBumpEnabled ? 'enabled' : 'disabled'}`);
52+
lines.push(`- **DLP**: ${manifest.dlpEnabled ? 'enabled' : 'disabled'}`);
53+
lines.push(`- **Host Access**: ${manifest.hostAccessEnabled ? 'enabled' : 'disabled'}`);
54+
lines.push(`- **DNS Servers**: ${manifest.dnsServers.join(', ')}`);
55+
lines.push(`- **Dangerous Ports Blocked**: ${manifest.dangerousPorts.length} ports\n`);
56+
57+
// Rule hits table
58+
lines.push('### Rule Evaluation\n');
59+
lines.push('| Rule | Action | Hits | Description |');
60+
lines.push('|------|--------|------|-------------|');
61+
for (const rule of ruleStats) {
62+
const actionIcon = rule.action === 'allow' ? '✅' : '🚫';
63+
const hitsStr = rule.hits > 0 ? `**${rule.hits}**` : '0';
64+
lines.push(`| ${rule.ruleId} | ${actionIcon} ${rule.action} | ${hitsStr} | ${rule.description} |`);
65+
}
66+
67+
// Denied requests detail
68+
const denied = entries.filter(e => !e.isAllowed && e.url !== 'error:transaction-end-before-headers');
69+
if (denied.length > 0) {
70+
lines.push('\n### Denied Requests\n');
71+
lines.push('| Timestamp | Domain | Rule | Reason |');
72+
lines.push('|-----------|--------|------|--------|');
73+
for (const entry of denied.slice(0, 50)) { // Cap at 50
74+
const ts = new Date(entry.timestamp * 1000).toISOString();
75+
lines.push(`| ${ts} | ${entry.domain} | ${entry.matchedRuleId} | ${entry.matchReason} |`);
76+
}
77+
if (denied.length > 50) {
78+
lines.push(`\n_...and ${denied.length - 50} more denied requests_`);
79+
}
80+
}
81+
82+
return lines.join('\n');
83+
}
84+
85+
function formatAuditPretty(entries: EnrichedLogEntry[], manifest: PolicyManifest, colorize: boolean): string {
86+
const c = colorize
87+
? chalk
88+
: (new Proxy({}, { get: () => (s: string) => s }) as typeof chalk);
89+
90+
const lines: string[] = [];
91+
const ruleStats = computeRuleStats(entries, manifest);
92+
93+
lines.push(c.bold('Firewall Audit Report'));
94+
lines.push(c.gray('─'.repeat(60)));
95+
lines.push('');
96+
97+
// Rule hits
98+
lines.push(c.bold('Rule Evaluation:'));
99+
const maxIdLen = Math.max(...ruleStats.map(r => r.ruleId.length));
100+
for (const rule of ruleStats) {
101+
const paddedId = rule.ruleId.padEnd(maxIdLen + 2);
102+
const actionStr = rule.action === 'allow' ? c.green(rule.action) : c.red(rule.action);
103+
const hitsStr = rule.hits > 0 ? c.bold(String(rule.hits)) : c.gray('0');
104+
lines.push(` ${paddedId}${actionStr} ${hitsStr} hits ${c.gray(rule.description)}`);
105+
}
106+
107+
// Denied requests
108+
const denied = entries.filter(e => !e.isAllowed && e.url !== 'error:transaction-end-before-headers');
109+
if (denied.length > 0) {
110+
lines.push('');
111+
lines.push(c.bold(`Denied Requests (${denied.length}):`));
112+
for (const entry of denied.slice(0, 20)) {
113+
const ts = new Date(entry.timestamp * 1000).toISOString().slice(11, 23);
114+
lines.push(` ${c.gray(ts)} ${c.red(entry.domain)} ${c.gray(`→ ${entry.matchedRuleId}`)}`);
115+
}
116+
if (denied.length > 20) {
117+
lines.push(c.gray(` ...and ${denied.length - 20} more`));
118+
}
119+
}
120+
121+
lines.push('');
122+
return lines.join('\n');
123+
}
124+
125+
/**
126+
* Main handler for the `awf logs audit` subcommand
127+
*/
128+
export async function auditCommand(options: AuditCommandOptions): Promise<void> {
129+
const source = await discoverAndSelectSource(options.source, {
130+
format: options.format,
131+
shouldLog: (format) => format !== 'json',
132+
});
133+
134+
// Load raw log entries
135+
const entries = await loadAllLogs(source);
136+
137+
if (entries.length === 0) {
138+
logger.error('No log entries found.');
139+
process.exit(1);
140+
}
141+
142+
// Find policy manifest (uses shared discovery logic)
143+
const manifest = findPolicyManifestForSource(source);
144+
145+
if (!manifest) {
146+
logger.error(
147+
'No policy-manifest.json found. The audit command requires a policy manifest.\n' +
148+
'Ensure you are using a version of awf that generates audit artifacts (--audit-dir).'
149+
);
150+
process.exit(1);
151+
}
152+
153+
// Enrich entries with rule matching
154+
let enriched = enrichWithPolicyRules(entries, manifest);
155+
156+
// Apply filters
157+
if (options.rule) {
158+
enriched = enriched.filter(e => e.matchedRuleId === options.rule);
159+
}
160+
if (options.domain) {
161+
const domainFilter = options.domain.toLowerCase();
162+
enriched = enriched.filter(e => e.domain.toLowerCase().includes(domainFilter));
163+
}
164+
if (options.decision) {
165+
const wantAllowed = options.decision === 'allowed';
166+
enriched = enriched.filter(e => e.isAllowed === wantAllowed);
167+
}
168+
169+
// Filter out benign operational entries
170+
const meaningful = enriched.filter(e => e.url !== 'error:transaction-end-before-headers');
171+
172+
// Format and output
173+
const colorize = !!(process.stdout.isTTY && options.format === 'pretty');
174+
let output: string;
175+
176+
switch (options.format) {
177+
case 'json':
178+
output = formatAuditJson(meaningful);
179+
break;
180+
case 'markdown':
181+
output = formatAuditMarkdown(meaningful, manifest);
182+
break;
183+
case 'pretty':
184+
default:
185+
output = formatAuditPretty(meaningful, manifest, colorize);
186+
break;
187+
}
188+
189+
console.log(output);
190+
}

0 commit comments

Comments
 (0)