feat: AI Agent Security Firewall MVP - CLI monitoring, sandboxing, audit logging, 11 tests passing
This commit is contained in:
@@ -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<string, number> } {
|
||||
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<string, number>
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+270
@@ -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 <command> [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 <path> 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<void> {
|
||||
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<void> {
|
||||
if (args.length < 2) {
|
||||
console.error('Usage: aasf run <command> [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 <path>');
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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>): Config {
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...partial,
|
||||
rules: partial.rules || DEFAULT_CONFIG.rules
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): Config {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
||||
}
|
||||
@@ -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<typeof watch>[] = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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, '<<<DOUBLESTAR>>>')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/<<<DOUBLESTAR>>>/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;
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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<string, string> = {
|
||||
'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;
|
||||
}
|
||||
}
|
||||
+296
@@ -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<SandboxResult> {
|
||||
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 <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user