From f1c4fe68e125b365e131a7a17398105d5afc0a9d Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Mon, 15 Jun 2026 12:24:35 +0000 Subject: [PATCH] feat: AI Agent Security Firewall MVP - CLI monitoring, sandboxing, audit logging, 11 tests passing --- .aasf.json | 72 +++++++++ .gitignore | 15 ++ package-lock.json | 52 +++++++ package.json | 23 +++ src/audit.ts | 74 +++++++++ src/cli.ts | 270 ++++++++++++++++++++++++++++++++ src/config.ts | 64 ++++++++ src/fs-monitor.ts | 87 +++++++++++ src/minimatch.ts | 23 +++ src/network-monitor.ts | 133 ++++++++++++++++ src/sandbox.ts | 296 ++++++++++++++++++++++++++++++++++++ src/tests/core.test.ts | 128 ++++++++++++++++ src/tests/minimatch.test.ts | 25 +++ src/types.ts | 55 +++++++ tsconfig.json | 19 +++ 15 files changed, 1336 insertions(+) create mode 100644 .aasf.json create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/audit.ts create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/fs-monitor.ts create mode 100644 src/minimatch.ts create mode 100644 src/network-monitor.ts create mode 100644 src/sandbox.ts create mode 100644 src/tests/core.test.ts create mode 100644 src/tests/minimatch.test.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.aasf.json b/.aasf.json new file mode 100644 index 0000000..4b46e55 --- /dev/null +++ b/.aasf.json @@ -0,0 +1,72 @@ +{ + "watchPaths": [ + "/home/node/.openclaw/workspace/builds/ai-agent-security-firewall" + ], + "auditLogPath": "/home/node/.aasf/audit.log", + "rules": [ + { + "id": "rule-001", + "name": "Block SSH Keys", + "type": "file", + "action": "block", + "pattern": "**/.ssh/*", + "description": "Prevent AI agents from reading SSH private keys" + }, + { + "id": "rule-002", + "name": "Block .env Files", + "type": "file", + "action": "flag", + "pattern": "**/.env*", + "description": "Flag access to environment files" + }, + { + "id": "rule-003", + "name": "Block Credential Files", + "type": "file", + "action": "block", + "pattern": "**/*.{pem,key,p12,pfx}", + "description": "Prevent reading of private keys and certificates" + }, + { + "id": "rule-004", + "name": "Flag Suspicious Network", + "type": "network", + "action": "flag", + "pattern": "pastebin.com", + "description": "Flag connections to paste services" + }, + { + "id": "rule-005", + "name": "Block Untrusted Host", + "type": "network", + "action": "block", + "pattern": "requests.malicious.example.com", + "description": "Block known malicious domains" + }, + { + "id": "rule-006", + "name": "Flag Process Spawning", + "type": "process", + "action": "flag", + "pattern": "curl|wget|nc|netcat", + "description": "Flag network tool process spawning" + } + ], + "maxLogSizeMB": 50, + "enableNetworkMonitoring": true, + "enableProcessMonitoring": true, + "blockedHosts": [ + "pastebin.com", + "termbin.com", + "requestbin.net" + ], + "blockedFilePatterns": [ + "**/.ssh/*", + "**/.aws/*", + "**/.config/gcloud/*", + "**/*.pem", + "**/*.key" + ], + "alertThreshold": 5 +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ab186f --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +.venv/ +*.log +.verdict +.env +.env.local +*.swp +*.swo +*~ +.DS_Store +.idea/ +.vscode/ +coverage/ +*.tsbuildinfo diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..47807a5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,52 @@ +{ + "name": "ai-agent-security-firewall", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-agent-security-firewall", + "version": "1.0.0", + "license": "MIT", + "bin": { + "aasf": "dist/cli.js", + "ai-agent-firewall": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d738a3 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "ai-agent-security-firewall", + "version": "1.0.0", + "description": "CLI tool that monitors, sandboxes, and restricts AI coding agents' file system and network access", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "aasf": "dist/cli.js", + "ai-agent-firewall": "dist/cli.js" + }, + "scripts": { + "build": "tsc", + "test": "node --test dist/**/*.test.js", + "start": "node dist/cli.js" + }, + "keywords": ["security", "ai-agent", "sandbox", "monitor", "firewall"], + "author": "BunBun Labs", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } +} diff --git a/src/audit.ts b/src/audit.ts new file mode 100644 index 0000000..b8fe6d9 --- /dev/null +++ b/src/audit.ts @@ -0,0 +1,74 @@ +import { writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, renameSync } from 'fs'; +import { dirname } from 'path'; +import type { AuditEvent, Config } from './types'; + +export class AuditLogger { + private config: Config; + private logPath: string; + + constructor(config: Config) { + this.config = config; + this.logPath = config.auditLogPath; + mkdirSync(dirname(this.logPath), { recursive: true }); + if (!existsSync(this.logPath)) { + writeFileSync(this.logPath, '', 'utf-8'); + } + } + + log(event: AuditEvent): void { + const line = JSON.stringify(event) + '\n'; + appendFileSync(this.logPath, line, 'utf-8'); + this.rotateIfNeeded(); + } + + logBulk(events: AuditEvent[]): void { + if (events.length === 0) return; + const lines = events.map(e => JSON.stringify(e)).join('\n') + '\n'; + appendFileSync(this.logPath, lines, 'utf-8'); + this.rotateIfNeeded(); + } + + readRecent(count: number = 100): AuditEvent[] { + if (!existsSync(this.logPath)) return []; + const data = require('fs').readFileSync(this.logPath, 'utf-8'); + const lines: string[] = data.split('\n').filter(Boolean); + return lines.slice(-count).map((line: string): AuditEvent => JSON.parse(line)).reverse(); + } + + summary(): { total: number; violations: number; warnings: number; byType: Record } { + if (!existsSync(this.logPath)) return { total: 0, violations: 0, warnings: 0, byType: {} }; + const data = require('fs').readFileSync(this.logPath, 'utf-8'); + const lines = data.split('\n').filter(Boolean); + const events = lines.map((line: string): AuditEvent => JSON.parse(line) as AuditEvent); + + const summary = { + total: events.length, + violations: events.filter((e: AuditEvent) => e.severity === 'critical').length, + warnings: events.filter((e: AuditEvent) => e.severity === 'warning').length, + byType: {} as Record + }; + + for (const event of events) { + summary.byType[event.type] = (summary.byType[event.type] || 0) + 1; + } + + return summary; + } + + private rotateIfNeeded(): void { + try { + const stats = statSync(this.logPath); + const maxBytes = this.config.maxLogSizeMB * 1024 * 1024; + if (stats.size > maxBytes) { + const rotated = this.logPath + '.1'; + if (existsSync(rotated)) { + require('fs').unlinkSync(rotated); + } + renameSync(this.logPath, rotated); + writeFileSync(this.logPath, '', 'utf-8'); + } + } catch (_e) { + // ignore rotation errors + } + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..96713e7 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,270 @@ +#!/usr/bin/env node +import { readFileSync, existsSync, mkdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { homedir } from 'os'; +import { loadConfig, saveConfig, getDefaultConfig } from './config'; +import { AuditLogger } from './audit'; +import { FilesystemMonitor } from './fs-monitor'; +import { NetworkMonitor } from './network-monitor'; +import { RulesEngine, SandboxRunner, createLdPreloadLib } from './sandbox'; +import type { Config } from './types'; + +const args = process.argv.slice(2); +const command = args[0]; + +function printHelp(): void { + console.log(` +AI Agent Security Firewall (aasf) — Monitor and sandbox AI coding agents + +Usage: + aasf init Create default config in current directory + aasf monitor [paths...] Start monitoring filesystem and network + aasf run [args...] Run a command inside the sandbox + aasf audit [count] Show recent audit events (default 100) + aasf summary Show audit summary + aasf status Show firewall status and rules + aasf rules List all active rules + aasf check Check if a file access would be blocked + aasf build-ldpreload Build the LD_PRELOAD library (Linux only) + aasf help Show this help + +Examples: + aasf init + aasf monitor ./src ./config + aasf run node my-ai-agent.js + aasf audit 50 + aasf check ~/.ssh/id_rsa +`); +} + +function printRules(config: Config): void { + console.log('\nActive Rules:'); + console.log('─'.repeat(60)); + for (const rule of config.rules) { + const actionColor = rule.action === 'block' ? 'BLOCK' : rule.action === 'flag' ? 'FLAG' : 'ALLOW'; + console.log(` ${rule.id} [${actionColor}] ${rule.name}`); + console.log(` Type: ${rule.type}, Pattern: ${rule.pattern}`); + if (rule.description) console.log(` ${rule.description}`); + } + console.log('─'.repeat(60)); + console.log(`Blocked hosts: ${config.blockedHosts.join(', ') || 'none'}`); + console.log(`Blocked file patterns: ${config.blockedFilePatterns.join(', ') || 'none'}`); + console.log(`Alert threshold: ${config.alertThreshold} violations`); + console.log(); +} + +function printStatus(config: Config): void { + console.log('\nFirewall Status:'); + console.log('─'.repeat(60)); + console.log(`Config loaded: ${config.auditLogPath}`); + console.log(`Watch paths: ${config.watchPaths.join(', ')}`); + console.log(`Network monitoring: ${config.enableNetworkMonitoring ? 'ON' : 'OFF'}`); + console.log(`Process monitoring: ${config.enableProcessMonitoring ? 'ON' : 'OFF'}`); + console.log(`Rules active: ${config.rules.length}`); + console.log('─'.repeat(60)); + printRules(config); +} + +async function handleMonitor(): Promise { + const paths = args.slice(1).length > 0 ? args.slice(1).map(p => resolve(p)) : [process.cwd()]; + const config = loadConfig(); + config.watchPaths = paths; + + const logger = new AuditLogger(config); + const fsMonitor = new FilesystemMonitor(logger, config); + const netMonitor = new NetworkMonitor(logger, config); + const rules = new RulesEngine(config, logger); + + console.log('Starting AI Agent Security Firewall monitor...'); + console.log(`Watching paths: ${paths.join(', ')}`); + + fsMonitor.start(); + if (config.enableNetworkMonitoring) { + netMonitor.start(); + } + + console.log('Press Ctrl+C to stop\n'); + + // Keep alive + const shutdown = () => { + console.log('\nShutting down monitor...'); + fsMonitor.stop(); + netMonitor.stop(); + console.log('Monitor stopped.'); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + // Keep alive + setInterval(() => { + if (rules.exceedsThreshold()) { + console.warn(`[ALERT] Violation threshold (${config.alertThreshold}) exceeded!`); + } + }, 5000); +} + +async function handleRun(): Promise { + if (args.length < 2) { + console.error('Usage: aasf run [args...]'); + process.exit(1); + } + + const config = loadConfig(); + const logger = new AuditLogger(config); + const runner = new SandboxRunner(config, logger); + const cmd = args[1]; + const cmdArgs = args.slice(2); + + console.log(`Running: ${cmd} ${cmdArgs.join(' ')}`); + console.log('Rules active:', config.rules.length); + + const result = await runner.run(cmd, cmdArgs); + + console.log(`\nCommand exited with code: ${result.exitCode}`); + console.log(`Duration: ${result.duration}ms`); + console.log(`Events logged: ${result.events.length}`); + + if (result.violations.length > 0) { + console.log('\nViolations detected:'); + for (const v of result.violations) { + console.log(` [${v.severity.toUpperCase()}] ${v.details}`); + } + } + + process.exit(result.exitCode); +} + +function handleAudit(): void { + const count = parseInt(args[1] || '100', 10); + const config = loadConfig(); + const logger = new AuditLogger(config); + const events = logger.readRecent(count); + + if (events.length === 0) { + console.log('No audit events found.'); + return; + } + + console.log(`\nLast ${events.length} audit events:`); + console.log('─'.repeat(80)); + for (const event of events) { + const ts = new Date(event.timestamp).toLocaleTimeString(); + const severity = event.severity.toUpperCase().padEnd(8); + console.log(`${ts} [${severity}] ${event.type.padEnd(12)} ${event.action.padEnd(6)} ${event.target}`); + if (event.details) console.log(` → ${event.details}`); + } + console.log('─'.repeat(80)); +} + +function handleSummary(): void { + const config = loadConfig(); + const logger = new AuditLogger(config); + const summary = logger.summary(); + + console.log('\nAudit Summary:'); + console.log('─'.repeat(40)); + console.log(`Total events: ${summary.total}`); + console.log(`Violations: ${summary.violations}`); + console.log(`Warnings: ${summary.warnings}`); + console.log('\nBy type:'); + for (const [type, count] of Object.entries(summary.byType)) { + console.log(` ${type}: ${count}`); + } + console.log('─'.repeat(40)); +} + +function handleCheck(): void { + if (args.length < 2) { + console.error('Usage: aasf check '); + process.exit(1); + } + + const config = loadConfig(); + const logger = new AuditLogger(config); + const rules = new RulesEngine(config, logger); + const path = resolve(args[1]); + + const result = rules.checkFileAccess(path); + + console.log(`\nChecking: ${path}`); + if (!result.allowed) { + console.log(`Result: BLOCKED by rule "${result.rule?.name}"`); + console.log(`Rule: ${result.rule?.id} — ${result.rule?.description || 'No description'}`); + } else if (result.flagged) { + console.log(`Result: FLAGGED by rule "${result.rule?.name}"`); + console.log(`Rule: ${result.rule?.id} — ${result.rule?.description || 'No description'}`); + } else { + console.log('Result: ALLOWED'); + } +} + +function handleInit(): void { + const configPath = join(process.cwd(), '.aasf.json'); + if (existsSync(configPath)) { + console.log('Config already exists at .aasf.json'); + return; + } + + const config = getDefaultConfig(); + saveConfig(config, configPath); + console.log(`Created default config at .aasf.json`); + console.log('Edit this file to customize rules and watch paths.'); +} + +function handleBuildLdPreload(): void { + const soPath = createLdPreloadLib(); + if (soPath) { + console.log(`Built LD_PRELOAD library at: ${soPath}`); + console.log('Usage: LD_PRELOAD=/path/to/aafs_hook.so node your-ai-agent.js'); + } else { + console.log('Failed to build LD_PRELOAD library. Ensure gcc and libc development headers are installed.'); + console.log('This is an optional hardening layer. The firewall works without it.'); + } +} + +async function main(): Promise { + try { + switch (command) { + case 'init': + handleInit(); + break; + case 'monitor': + await handleMonitor(); + break; + case 'run': + await handleRun(); + break; + case 'audit': + handleAudit(); + break; + case 'summary': + handleSummary(); + break; + case 'status': + printStatus(loadConfig()); + break; + case 'rules': + printRules(loadConfig()); + break; + case 'check': + handleCheck(); + break; + case 'build-ldpreload': + handleBuildLdPreload(); + break; + case 'help': + case '--help': + case '-h': + default: + printHelp(); + break; + } + } catch (e) { + console.error('Error:', e); + process.exit(1); + } +} + +main(); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3da3314 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,64 @@ +import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import type { Config } from './types'; + +const DEFAULT_CONFIG: Config = { + watchPaths: [process.cwd()], + auditLogPath: join(homedir(), '.aasf', 'audit.log'), + rules: [ + { id: 'rule-001', name: 'Block SSH Keys', type: 'file', action: 'block', pattern: '**/.ssh/*', description: 'Prevent AI agents from reading SSH private keys' }, + { id: 'rule-002', name: 'Block .env Files', type: 'file', action: 'flag', pattern: '**/.env*', description: 'Flag access to environment files' }, + { id: 'rule-003', name: 'Block Credential Files', type: 'file', action: 'block', pattern: '**/*.{pem,key,p12,pfx}', description: 'Prevent reading of private keys and certificates' }, + { id: 'rule-004', name: 'Flag Suspicious Network', type: 'network', action: 'flag', pattern: 'pastebin.com', description: 'Flag connections to paste services' }, + { id: 'rule-005', name: 'Block Untrusted Host', type: 'network', action: 'block', pattern: 'requests.malicious.example.com', description: 'Block known malicious domains' }, + { id: 'rule-006', name: 'Flag Process Spawning', type: 'process', action: 'flag', pattern: 'curl|wget|nc|netcat', description: 'Flag network tool process spawning' } + ], + maxLogSizeMB: 50, + enableNetworkMonitoring: true, + enableProcessMonitoring: true, + blockedHosts: ['pastebin.com', 'termbin.com', 'requestbin.net'], + blockedFilePatterns: ['**/.ssh/*', '**/.aws/*', '**/.config/gcloud/*', '**/*.pem', '**/*.key'], + alertThreshold: 5 +}; + +export function loadConfig(): Config { + const configPaths = [ + join(process.cwd(), '.aasf.json'), + join(process.cwd(), '.aasf.config.js'), + join(homedir(), '.aasf', 'config.json') + ]; + + for (const path of configPaths) { + if (existsSync(path)) { + try { + if (path.endsWith('.json')) { + const raw = readFileSync(path, 'utf-8'); + return mergeConfig(JSON.parse(raw)); + } + } catch (e) { + console.warn(`Warning: Failed to parse config at ${path}: ${e}`); + } + } + } + + return DEFAULT_CONFIG; +} + +export function saveConfig(config: Config, path?: string): void { + const configPath = path || join(homedir(), '.aasf', 'config.json'); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); +} + +function mergeConfig(partial: Partial): Config { + return { + ...DEFAULT_CONFIG, + ...partial, + rules: partial.rules || DEFAULT_CONFIG.rules + }; +} + +export function getDefaultConfig(): Config { + return JSON.parse(JSON.stringify(DEFAULT_CONFIG)); +} diff --git a/src/fs-monitor.ts b/src/fs-monitor.ts new file mode 100644 index 0000000..70686d2 --- /dev/null +++ b/src/fs-monitor.ts @@ -0,0 +1,87 @@ +import { watch, existsSync, statSync, realpathSync } from 'fs'; +import { resolve, sep } from 'path'; +import type { AuditLogger } from './audit'; +import type { Config, FileEvent, AuditEvent } from './types'; +import { minimatch } from './minimatch'; + +export class FilesystemMonitor { + private watchers: ReturnType[] = []; + private logger: AuditLogger; + private config: Config; + private active: boolean = false; + + constructor(logger: AuditLogger, config: Config) { + this.logger = logger; + this.config = config; + } + + start(): void { + if (this.active) return; + this.active = true; + + for (const dir of this.config.watchPaths) { + if (!existsSync(dir)) { + console.warn(`Watch path does not exist: ${dir}`); + continue; + } + const watcher = watch(dir, { recursive: true }, (eventType, filename) => { + if (!filename || !this.active) return; + const fullPath = resolve(dir, filename); + this.handleEvent(eventType as string, fullPath); + }); + this.watchers.push(watcher); + } + } + + stop(): void { + this.active = false; + for (const w of this.watchers) { + w.close(); + } + this.watchers = []; + } + + private handleEvent(eventType: string, fullPath: string): void { + const event: FileEvent = { + path: fullPath, + eventType: this.mapEventType(eventType), + timestamp: new Date().toISOString() + }; + + const matchedRule = this.matchFileRule(fullPath); + const severity = matchedRule ? (matchedRule.action === 'block' ? 'critical' : 'warning') : 'info'; + const action = matchedRule ? matchedRule.action : 'allow'; + + const auditEvent: AuditEvent = { + timestamp: new Date().toISOString(), + type: 'file', + severity, + action: action, + target: fullPath, + details: `File ${event.eventType}: ${fullPath}`, + ruleId: matchedRule?.id + }; + + this.logger.log(auditEvent); + + if (matchedRule && matchedRule.action === 'block') { + console.error(`[BLOCKED] ${matchedRule.name}: ${fullPath}`); + } + } + + private mapEventType(raw: string): 'create' | 'modify' | 'delete' | 'rename' { + if (raw === 'rename') return 'rename'; + if (raw === 'change') return 'modify'; + return 'create'; + } + + private matchFileRule(path: string): { id: string; name: string; action: string } | null { + for (const rule of this.config.rules) { + if (rule.type !== 'file') continue; + if (minimatch(path, rule.pattern)) { + return { id: rule.id, name: rule.name, action: rule.action }; + } + } + return null; + } +} diff --git a/src/minimatch.ts b/src/minimatch.ts new file mode 100644 index 0000000..0cb9610 --- /dev/null +++ b/src/minimatch.ts @@ -0,0 +1,23 @@ +/** + * Minimal glob matcher without external deps + */ +export function minimatch(path: string, pattern: string): boolean { + // Convert **/ and glob patterns to regex + const regexPattern = pattern + .replace(/\*\*/g, '<<>>') + .replace(/\*/g, '[^/]*') + .replace(/<<>>/g, '.*'); + + const parts = path.split('/'); + const filename = parts[parts.length - 1]; + const basename = filename; + + // Check full path match + const re = new RegExp(regexPattern.replace(/\?/g, '.')); + if (re.test(path)) return true; + + // Check basename match for patterns like *.pem + if (re.test(basename)) return true; + + return false; +} diff --git a/src/network-monitor.ts b/src/network-monitor.ts new file mode 100644 index 0000000..c8bc6fd --- /dev/null +++ b/src/network-monitor.ts @@ -0,0 +1,133 @@ +import { readFileSync, existsSync } from 'fs'; +import type { AuditLogger } from './audit'; +import type { Config, NetworkConnection, AuditEvent } from './types'; + +export class NetworkMonitor { + private logger: AuditLogger; + private config: Config; + private interval: ReturnType | null = null; + + constructor(logger: AuditLogger, config: Config) { + this.logger = logger; + this.config = config; + } + + start(intervalMs: number = 5000): void { + if (this.interval) return; + this.interval = setInterval(() => this.scan(), intervalMs); + this.scan(); + } + + stop(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + scan(): void { + if (!existsSync('/proc/net/tcp')) { + // On systems without /proc/net/tcp, try to at least detect via netstat if available + return; + } + const connections = this.parseTcpConnections(); + for (const conn of connections) { + const matchedRule = this.matchNetworkRule(conn); + if (matchedRule) { + const severity = matchedRule.action === 'block' ? 'critical' : 'warning'; + const event: AuditEvent = { + timestamp: new Date().toISOString(), + type: 'network', + severity, + action: matchedRule.action, + target: `${conn.remoteAddress}:${conn.remotePort}`, + details: `TCP connection to ${conn.remoteAddress}:${conn.remotePort} (state: ${conn.state})`, + ruleId: matchedRule.id + }; + this.logger.log(event); + if (matchedRule.action === 'block') { + console.error(`[BLOCKED] ${matchedRule.name}: ${conn.remoteAddress}:${conn.remotePort}`); + } + } + } + } + + parseTcpConnections(): NetworkConnection[] { + try { + const data = readFileSync('/proc/net/tcp', 'utf-8'); + const lines = data.split('\n').slice(1); // skip header + const connections: NetworkConnection[] = []; + + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts.length < 10) continue; + const localAddr = parts[1]; + const remAddr = parts[2]; + const state = parts[3]; + const inode = parts[9]; + const uid = parseInt(parts[7], 10); + + const [localHex, localPort] = localAddr.split(':'); + const [remHex, remPort] = remAddr.split(':'); + + connections.push({ + localAddress: this.hexToIp(localHex), + localPort: parseInt(localPort, 16).toString(), + remoteAddress: this.hexToIp(remHex), + remotePort: parseInt(remPort, 16).toString(), + state: this.stateToString(state), + inode, + uid + }); + } + return connections; + } catch (e) { + return []; + } + } + + private hexToIp(hex: string): string { + if (!hex) return ''; + const parts = hex.match(/../g); + if (!parts || parts.length !== 4) return ''; + return parts.reverse().map(p => parseInt(p, 16)).join('.'); + } + + private stateToString(state: string): string { + const states: Record = { + '01': 'ESTABLISHED', + '02': 'SYN_SENT', + '03': 'SYN_RECV', + '04': 'FIN_WAIT1', + '05': 'FIN_WAIT2', + '06': 'TIME_WAIT', + '07': 'CLOSE', + '08': 'CLOSE_WAIT', + '09': 'LAST_ACK', + '0A': 'LISTEN', + '0B': 'CLOSING' + }; + return states[state] || state; + } + + private matchNetworkRule(conn: NetworkConnection): { id: string; name: string; action: string } | null { + for (const rule of this.config.rules) { + if (rule.type !== 'network') continue; + // Check if remote address matches pattern + if (rule.pattern === conn.remoteAddress || conn.remoteAddress.includes(rule.pattern)) { + return { id: rule.id, name: rule.name, action: rule.action }; + } + // Check blocked hosts list too + if (this.config.blockedHosts.includes(conn.remoteAddress)) { + return { id: 'config-block', name: 'Config Blocked Host', action: 'block' }; + } + // Check if state is ESTABLISHED and host is suspicious + for (const host of this.config.blockedHosts) { + if (conn.remoteAddress.includes(host)) { + return { id: 'config-block', name: 'Config Blocked Host', action: 'block' }; + } + } + } + return null; + } +} diff --git a/src/sandbox.ts b/src/sandbox.ts new file mode 100644 index 0000000..a0eb2e6 --- /dev/null +++ b/src/sandbox.ts @@ -0,0 +1,296 @@ +import { spawn, execSync } from 'child_process'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { AuditLogger } from './audit'; +import type { Config, AuditEvent, SandboxResult, Rule } from './types'; +import { minimatch } from './minimatch'; + +export class RulesEngine { + private config: Config; + private logger: AuditLogger; + private violationCount: number = 0; + + constructor(config: Config, logger: AuditLogger) { + this.config = config; + this.logger = logger; + } + + checkFileAccess(path: string): { allowed: boolean; flagged: boolean; rule?: Rule } { + for (const rule of this.config.rules) { + if (rule.type !== 'file') continue; + if (minimatch(path, rule.pattern)) { + if (rule.action === 'block') { + this.logViolation(rule, path, 'file'); + return { allowed: false, flagged: true, rule }; + } + if (rule.action === 'flag') { + this.logViolation(rule, path, 'file'); + return { allowed: true, flagged: true, rule }; + } + } + } + return { allowed: true, flagged: false }; + } + + checkNetwork(host: string): { allowed: boolean; flagged: boolean; rule?: Rule } { + for (const rule of this.config.rules) { + if (rule.type !== 'network') continue; + if (host.includes(rule.pattern) || rule.pattern === host) { + if (rule.action === 'block') { + this.logViolation(rule, host, 'network'); + return { allowed: false, flagged: true, rule }; + } + if (rule.action === 'flag') { + this.logViolation(rule, host, 'network'); + return { allowed: true, flagged: true, rule }; + } + } + } + // Check config-level blocked hosts + for (const blocked of this.config.blockedHosts) { + if (host.includes(blocked)) { + const rule: Rule = { id: 'config-block', name: 'Blocked Host', type: 'network', action: 'block', pattern: blocked }; + this.logViolation(rule, host, 'network'); + return { allowed: false, flagged: true, rule }; + } + } + return { allowed: true, flagged: false }; + } + + checkProcess(command: string): { allowed: boolean; flagged: boolean; rule?: Rule } { + for (const rule of this.config.rules) { + if (rule.type !== 'process') continue; + const pattern = rule.pattern.replace(/\|/g, '|'); + const parts = pattern.split('|'); + for (const part of parts) { + if (command.includes(part.trim())) { + if (rule.action === 'block') { + this.logViolation(rule, command, 'process'); + return { allowed: false, flagged: true, rule }; + } + if (rule.action === 'flag') { + this.logViolation(rule, command, 'process'); + return { allowed: true, flagged: true, rule }; + } + } + } + } + return { allowed: true, flagged: false }; + } + + getViolationCount(): number { + return this.violationCount; + } + + exceedsThreshold(): boolean { + return this.violationCount >= this.config.alertThreshold; + } + + private logViolation(rule: Rule, target: string, type: 'file' | 'network' | 'process'): void { + this.violationCount++; + const event: AuditEvent = { + timestamp: new Date().toISOString(), + type: 'rule_violation', + severity: rule.action === 'block' ? 'critical' : 'warning', + action: rule.action, + target, + details: `Rule "${rule.name}" triggered for ${type}: ${target}`, + ruleId: rule.id + }; + this.logger.log(event); + } +} + +export class SandboxRunner { + private logger: AuditLogger; + private config: Config; + private rules: RulesEngine; + + constructor(config: Config, logger: AuditLogger) { + this.config = config; + this.logger = logger; + this.rules = new RulesEngine(config, logger); + } + + run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + const events: AuditEvent[] = []; + const violations: AuditEvent[] = []; + + // Pre-check: flag command itself + const processCheck = this.rules.checkProcess(command + ' ' + args.join(' ')); + if (!processCheck.allowed) { + const event: AuditEvent = { + timestamp: new Date().toISOString(), + type: 'rule_violation', + severity: 'critical', + action: 'block', + target: command, + details: `Command blocked by rule: ${processCheck.rule?.name || 'unknown'}` + }; + events.push(event); + violations.push(event); + this.logger.log(event); + return resolve({ + command: command + ' ' + args.join(' '), + exitCode: 1, + events, + violations, + duration: 0 + }); + } + if (processCheck.flagged) { + const event: AuditEvent = { + timestamp: new Date().toISOString(), + type: 'process', + severity: 'warning', + action: 'flag', + target: command, + details: `Command flagged by rule: ${processCheck.rule?.name || 'unknown'}` + }; + events.push(event); + this.logger.log(event); + } + + const child = spawn(command, args, { + shell: false, + env: { ...process.env, AASF_SANDBOXED: '1' }, + cwd: process.cwd() + }); + + child.stdout.on('data', (data) => { + // Monitor stdout for suspicious patterns (e.g., API keys, tokens) + const text = data.toString(); + this.detectLeaks(text, events); + }); + + child.stderr.on('data', (data) => { + const text = data.toString(); + this.detectLeaks(text, events); + }); + + child.on('exit', (code) => { + const duration = Date.now() - startTime; + this.logger.logBulk(events); + resolve({ + command: command + ' ' + args.join(' '), + exitCode: code || 0, + events, + violations: events.filter(e => e.severity === 'critical'), + duration + }); + }); + + child.on('error', (err) => { + const duration = Date.now() - startTime; + const event: AuditEvent = { + timestamp: new Date().toISOString(), + type: 'process', + severity: 'warning', + action: 'error', + target: command, + details: `Spawn error: ${err.message}` + }; + events.push(event); + this.logger.logBulk(events); + resolve({ + command: command + ' ' + args.join(' '), + exitCode: 1, + events, + violations: events.filter(e => e.severity === 'critical'), + duration + }); + }); + }); + } + + private detectLeaks(text: string, events: AuditEvent[]): void { + const leakPatterns = [ + { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/g }, + { name: 'Private Key Block', pattern: /-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----/g }, + { name: 'API Key Pattern', pattern: /['"\s](sk-[a-zA-Z0-9]{20,})['"\s]/g }, + { name: 'GitHub Token', pattern: /gh[pousr]_[a-zA-Z0-9]{36}/g }, + { name: 'Generic Secret', pattern: /['"\s]([a-zA-Z0-9]{32,64})['"\s]/g } + ]; + + for (const { name, pattern } of leakPatterns) { + const matches = text.match(pattern); + if (matches) { + events.push({ + timestamp: new Date().toISOString(), + type: 'process', + severity: 'warning', + action: 'flag', + target: 'stdout/stderr', + details: `Potential ${name} detected in output` + }); + } + } + } +} + +export function generateLdPreloadScript(): string { + // Generate a C LD_PRELOAD library that intercepts file system calls + const c = ` +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *BLOCKED_PATTERNS[] = { + "/.ssh/", "/.aws/", ".env", ".pem", ".key", ".p12", ".pfx", NULL +}; + +static int is_blocked(const char *path) { + if (!path) return 0; + for (int i = 0; BLOCKED_PATTERNS[i]; i++) { + if (strstr(path, BLOCKED_PATTERNS[i])) return 1; + } + return 0; +} + +static int (*real_open)(const char *, int, ...) = NULL; +static int (*real_open64)(const char *, int, ...) = NULL; +static int (*real_fopen)(const char *, const char *) = NULL; + +int open(const char *pathname, int flags, ...) { + if (!real_open) real_open = dlsym(RTLD_NEXT, "open"); + if (is_blocked(pathname)) { + fprintf(stderr, "[AASF BLOCKED] open: %s\\n", pathname); + return -1; + } + return real_open(pathname, flags); +} + +int open64(const char *pathname, int flags, ...) { + if (!real_open64) real_open64 = dlsym(RTLD_NEXT, "open64"); + if (is_blocked(pathname)) { + fprintf(stderr, "[AASF BLOCKED] open64: %s\\n", pathname); + return -1; + } + return real_open64(pathname, flags); +} +`; + return c; +} + +export function createLdPreloadLib(): string | null { + try { + const libDir = join(homedir(), '.aasf', 'lib'); + mkdirSync(libDir, { recursive: true }); + const cPath = join(libDir, 'aafs_hook.c'); + const soPath = join(libDir, 'aafs_hook.so'); + writeFileSync(cPath, generateLdPreloadScript(), 'utf-8'); + execSync(`gcc -shared -fPIC -o ${soPath} ${cPath} -ldl`, { stdio: 'ignore' }); + return soPath; + } catch (e) { + return null; + } +} diff --git a/src/tests/core.test.ts b/src/tests/core.test.ts new file mode 100644 index 0000000..2463c0e --- /dev/null +++ b/src/tests/core.test.ts @@ -0,0 +1,128 @@ +// @ts-nocheck +const { describe, it, beforeEach } = require('node:test'); +const assert = require('node:assert'); +const { loadConfig, getDefaultConfig, saveConfig } = require('../config'); +const { AuditLogger } = require('../audit'); +const { RulesEngine } = require('../sandbox'); +const { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } = require('fs'); +const { join } = require('path'); +const { tmpdir } = require('os'); + +describe('config', () => { + it('returns default config', () => { + const config = getDefaultConfig(); + assert.ok(config.watchPaths); + assert.ok(config.rules); + assert.strictEqual(config.rules.length > 0, true); + assert.ok(config.auditLogPath); + }); + + it('loads config from file', () => { + const tmpDir = join(tmpdir(), 'aasf-test-' + Date.now()); + mkdirSync(tmpDir, { recursive: true }); + const configPath = join(tmpDir, '.aasf.json'); + const config = { ...getDefaultConfig(), watchPaths: ['/test/path'] }; + saveConfig(config, configPath); + + assert.ok(existsSync(configPath)); + const loaded = JSON.parse(readFileSync(configPath, 'utf-8')); + assert.deepStrictEqual(loaded.watchPaths, ['/test/path']); + + rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +describe('audit logger', () => { + it('logs and reads events', () => { + const tmpDir = join(tmpdir(), 'aasf-audit-test-' + Date.now()); + mkdirSync(tmpDir, { recursive: true }); + const config = getDefaultConfig(); + config.auditLogPath = join(tmpDir, 'audit.log'); + + const logger = new AuditLogger(config); + const event = { + timestamp: new Date().toISOString(), + type: 'file', + severity: 'info', + action: 'allow', + target: '/test/file.txt', + details: 'Test event' + }; + + logger.log(event); + const events = logger.readRecent(10); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].target, '/test/file.txt'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns summary', () => { + const tmpDir = join(tmpdir(), 'aasf-audit-test-' + Date.now()); + mkdirSync(tmpDir, { recursive: true }); + const config = getDefaultConfig(); + config.auditLogPath = join(tmpDir, 'audit.log'); + + const logger = new AuditLogger(config); + logger.log({ timestamp: new Date().toISOString(), type: 'file', severity: 'info', action: 'allow', target: 'a', details: 'd' }); + logger.log({ timestamp: new Date().toISOString(), type: 'file', severity: 'critical', action: 'block', target: 'b', details: 'd' }); + + const summary = logger.summary(); + assert.strictEqual(summary.total, 2); + assert.strictEqual(summary.violations, 1); + + rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +describe('rules engine', () => { + it('blocks SSH key access', () => { + const tmpDir = join(tmpdir(), 'aasf-rules-test-' + Date.now()); + mkdirSync(tmpDir, { recursive: true }); + const config = getDefaultConfig(); + config.auditLogPath = join(tmpDir, 'audit.log'); + + const logger = new AuditLogger(config); + const rules = new RulesEngine(config, logger); + + const result = rules.checkFileAccess('/home/user/.ssh/id_rsa'); + assert.strictEqual(result.allowed, false); + assert.ok(result.flagged); + assert.ok(result.rule); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('allows benign file access', () => { + const tmpDir = join(tmpdir(), 'aasf-rules-test-' + Date.now()); + mkdirSync(tmpDir, { recursive: true }); + const config = getDefaultConfig(); + config.auditLogPath = join(tmpDir, 'audit.log'); + + const logger = new AuditLogger(config); + const rules = new RulesEngine(config, logger); + + const result = rules.checkFileAccess('/home/user/project/readme.md'); + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.flagged, false); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('flags .env access', () => { + const tmpDir = join(tmpdir(), 'aasf-rules-test-' + Date.now()); + mkdirSync(tmpDir, { recursive: true }); + const config = getDefaultConfig(); + config.auditLogPath = join(tmpDir, 'audit.log'); + + const logger = new AuditLogger(config); + const rules = new RulesEngine(config, logger); + + const result = rules.checkFileAccess('/home/user/project/.env'); + assert.strictEqual(result.allowed, true); + assert.strictEqual(result.flagged, true); + + rmSync(tmpDir, { recursive: true, force: true }); + }); +}); diff --git a/src/tests/minimatch.test.ts b/src/tests/minimatch.test.ts new file mode 100644 index 0000000..5b89c88 --- /dev/null +++ b/src/tests/minimatch.test.ts @@ -0,0 +1,25 @@ +// @ts-nocheck +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { minimatch } = require('../minimatch'); + +describe('minimatch', () => { + it('matches exact paths', () => { + assert.strictEqual(minimatch('/home/user/.ssh/id_rsa', '/home/user/.ssh/id_rsa'), true); + }); + + it('matches glob patterns', () => { + assert.strictEqual(minimatch('/home/user/.ssh/id_rsa', '**/.ssh/*'), true); + assert.strictEqual(minimatch('/home/user/.aws/credentials', '**/.aws/*'), true); + }); + + it('matches file extensions', () => { + assert.strictEqual(minimatch('/path/to/key.pem', '*.pem'), true); + assert.strictEqual(minimatch('/path/to/secret.key', '*.key'), true); + }); + + it('does not match unrelated paths', () => { + assert.strictEqual(minimatch('/home/user/readme.txt', '**/.ssh/*'), false); + assert.strictEqual(minimatch('/path/to/file.js', '*.pem'), false); + }); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0a4a05d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,55 @@ +export interface Rule { + id: string; + name: string; + type: 'file' | 'network' | 'process'; + action: 'allow' | 'block' | 'flag'; + pattern: string; + description?: string; +} + +export interface Config { + watchPaths: string[]; + auditLogPath: string; + rules: Rule[]; + maxLogSizeMB: number; + enableNetworkMonitoring: boolean; + enableProcessMonitoring: boolean; + blockedHosts: string[]; + blockedFilePatterns: string[]; + alertThreshold: number; +} + +export interface AuditEvent { + timestamp: string; + type: 'file' | 'network' | 'process' | 'rule_violation'; + severity: 'info' | 'warning' | 'critical'; + action: string; + target: string; + details: string; + pid?: number; + ruleId?: string; +} + +export interface NetworkConnection { + localAddress: string; + localPort: string; + remoteAddress: string; + remotePort: string; + state: string; + inode: string; + uid: number; +} + +export interface FileEvent { + path: string; + eventType: 'create' | 'modify' | 'delete' | 'rename'; + timestamp: string; +} + +export interface SandboxResult { + command: string; + exitCode: number; + events: AuditEvent[]; + violations: AuditEvent[]; + duration: number; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7a5f574 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}