@opena2a/oasb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +98 -0
- package/README.md +287 -0
- package/config/arp-lab-default.yaml +54 -0
- package/config/dvaa-targets.ts +97 -0
- package/dist/harness/arp-wrapper.d.ts +28 -0
- package/dist/harness/arp-wrapper.js +133 -0
- package/dist/harness/dvaa-client.d.ts +45 -0
- package/dist/harness/dvaa-client.js +97 -0
- package/dist/harness/dvaa-manager.d.ts +16 -0
- package/dist/harness/dvaa-manager.js +131 -0
- package/dist/harness/event-collector.d.ts +32 -0
- package/dist/harness/event-collector.js +85 -0
- package/dist/harness/metrics.d.ts +13 -0
- package/dist/harness/metrics.js +55 -0
- package/dist/harness/mock-llm-adapter.d.ts +33 -0
- package/dist/harness/mock-llm-adapter.js +68 -0
- package/dist/harness/types.d.ts +73 -0
- package/dist/harness/types.js +2 -0
- package/package.json +39 -0
- package/src/atomic/enforcement/AT-ENF-001.log-action.test.ts +89 -0
- package/src/atomic/enforcement/AT-ENF-002.alert-callback.test.ts +120 -0
- package/src/atomic/enforcement/AT-ENF-003.pause-sigstop.test.ts +104 -0
- package/src/atomic/enforcement/AT-ENF-004.kill-sigterm.test.ts +153 -0
- package/src/atomic/enforcement/AT-ENF-005.resume-sigcont.test.ts +164 -0
- package/src/atomic/filesystem/AT-FS-001.sensitive-path.test.ts +118 -0
- package/src/atomic/filesystem/AT-FS-002.outside-allowed.test.ts +122 -0
- package/src/atomic/filesystem/AT-FS-003.credential-file.test.ts +115 -0
- package/src/atomic/filesystem/AT-FS-004.mass-file-creation.test.ts +137 -0
- package/src/atomic/filesystem/AT-FS-005.dotfile-write.test.ts +154 -0
- package/src/atomic/intelligence/AT-INT-001.l0-rule-match.test.ts +107 -0
- package/src/atomic/intelligence/AT-INT-002.l1-anomaly-score.test.ts +94 -0
- package/src/atomic/intelligence/AT-INT-003.l2-escalation.test.ts +124 -0
- package/src/atomic/intelligence/AT-INT-004.budget-exhaustion.test.ts +108 -0
- package/src/atomic/intelligence/AT-INT-005.baseline-learning.test.ts +121 -0
- package/src/atomic/network/AT-NET-001.new-outbound.test.ts +103 -0
- package/src/atomic/network/AT-NET-002.suspicious-host.test.ts +82 -0
- package/src/atomic/network/AT-NET-003.connection-burst.test.ts +91 -0
- package/src/atomic/network/AT-NET-004.allowed-host-bypass.test.ts +129 -0
- package/src/atomic/network/AT-NET-005.exfil-destination.test.ts +117 -0
- package/src/atomic/process/AT-PROC-001.spawn-child.test.ts +148 -0
- package/src/atomic/process/AT-PROC-002.suspicious-binary.test.ts +123 -0
- package/src/atomic/process/AT-PROC-003.high-cpu.test.ts +120 -0
- package/src/atomic/process/AT-PROC-004.privilege-escalation.test.ts +114 -0
- package/src/atomic/process/AT-PROC-005.process-terminated.test.ts +150 -0
- package/src/baseline/BL-001.normal-agent-profile.test.ts +140 -0
- package/src/baseline/BL-002.anomaly-injection.test.ts +134 -0
- package/src/baseline/BL-003.baseline-persistence.test.ts +130 -0
- package/src/e2e/E2E-001.live-filesystem-detection.test.ts +129 -0
- package/src/e2e/E2E-002.live-process-detection.test.ts +106 -0
- package/src/e2e/E2E-003.live-network-detection.test.ts +114 -0
- package/src/e2e/E2E-004.interceptor-process.test.ts +125 -0
- package/src/e2e/E2E-005.interceptor-network.test.ts +134 -0
- package/src/e2e/E2E-006.interceptor-filesystem.test.ts +140 -0
- package/src/harness/arp-wrapper.ts +121 -0
- package/src/harness/dvaa-client.ts +130 -0
- package/src/harness/dvaa-manager.ts +106 -0
- package/src/harness/event-collector.ts +100 -0
- package/src/harness/metrics.ts +64 -0
- package/src/harness/mock-llm-adapter.ts +90 -0
- package/src/harness/types.ts +77 -0
- package/src/integration/INT-001.data-exfil-detection.test.ts +228 -0
- package/src/integration/INT-002.mcp-tool-abuse.test.ts +236 -0
- package/src/integration/INT-003.prompt-injection-response.test.ts +238 -0
- package/src/integration/INT-004.a2a-trust-exploitation.test.ts +280 -0
- package/src/integration/INT-005.baseline-then-attack.test.ts +239 -0
- package/src/integration/INT-006.multi-monitor-correlation.test.ts +265 -0
- package/src/integration/INT-007.budget-exhaustion-attack.test.ts +249 -0
- package/src/integration/INT-008.kill-switch-recovery.test.ts +314 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// E2E-003: Live Network Detection
|
|
2
|
+
// Proves ARP's NetworkMonitor detects real TCP connections via lsof/ss polling.
|
|
3
|
+
// No event injection — the monitor polls the OS network state directly.
|
|
4
|
+
//
|
|
5
|
+
// NOTE: Requires lsof (macOS) or ss (Linux) to be available.
|
|
6
|
+
// Test skips gracefully if neither tool is present.
|
|
7
|
+
//
|
|
8
|
+
// ATLAS: AML.T0024
|
|
9
|
+
// OWASP: A04
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import * as net from 'net';
|
|
14
|
+
import * as os from 'os';
|
|
15
|
+
import { ArpWrapper } from '../harness/arp-wrapper';
|
|
16
|
+
|
|
17
|
+
function hasNetworkTool(): boolean {
|
|
18
|
+
try {
|
|
19
|
+
if (os.platform() === 'darwin') {
|
|
20
|
+
execSync('which lsof', { encoding: 'utf-8', timeout: 2000 });
|
|
21
|
+
} else {
|
|
22
|
+
execSync('which ss', { encoding: 'utf-8', timeout: 2000 });
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('E2E-003: Live Network Detection', () => {
|
|
31
|
+
let networkAvailable: boolean;
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
networkAvailable = hasNetworkTool();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let arp: ArpWrapper;
|
|
38
|
+
let server: net.Server;
|
|
39
|
+
let serverPort: number;
|
|
40
|
+
let clientSocket: net.Socket | null = null;
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
if (!networkAvailable) return;
|
|
44
|
+
|
|
45
|
+
// Start a local TCP server on a random port
|
|
46
|
+
server = net.createServer((socket) => {
|
|
47
|
+
socket.on('data', (data) => socket.write(data));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await new Promise<void>((resolve) => {
|
|
51
|
+
server.listen(0, '127.0.0.1', () => {
|
|
52
|
+
const addr = server.address();
|
|
53
|
+
if (addr && typeof addr === 'object') {
|
|
54
|
+
serverPort = addr.port;
|
|
55
|
+
}
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
arp = new ArpWrapper({
|
|
61
|
+
monitors: {
|
|
62
|
+
process: false,
|
|
63
|
+
network: true,
|
|
64
|
+
filesystem: false,
|
|
65
|
+
},
|
|
66
|
+
networkIntervalMs: 1000,
|
|
67
|
+
});
|
|
68
|
+
await arp.start();
|
|
69
|
+
|
|
70
|
+
// Let the initial snapshot complete
|
|
71
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
if (!networkAvailable) return;
|
|
76
|
+
|
|
77
|
+
if (clientSocket) {
|
|
78
|
+
clientSocket.destroy();
|
|
79
|
+
clientSocket = null;
|
|
80
|
+
}
|
|
81
|
+
await arp.stop();
|
|
82
|
+
await new Promise<void>((resolve) => {
|
|
83
|
+
server.close(() => resolve());
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should detect a new outbound TCP connection', async () => {
|
|
88
|
+
if (!networkAvailable) {
|
|
89
|
+
console.log('SKIP: lsof/ss not available — network E2E test requires system tools');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
clientSocket = net.connect({ host: '127.0.0.1', port: serverPort });
|
|
94
|
+
|
|
95
|
+
await new Promise<void>((resolve, reject) => {
|
|
96
|
+
clientSocket!.on('connect', resolve);
|
|
97
|
+
clientSocket!.on('error', reject);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Send data to ensure connection is fully active
|
|
101
|
+
clientSocket.write('test-payload');
|
|
102
|
+
|
|
103
|
+
const event = await arp.waitForEvent(
|
|
104
|
+
(e) =>
|
|
105
|
+
e.source === 'network' &&
|
|
106
|
+
e.data.remotePort === serverPort,
|
|
107
|
+
15000,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(event).toBeDefined();
|
|
111
|
+
expect(event.source).toBe('network');
|
|
112
|
+
expect(event.data.remotePort).toBe(serverPort);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// E2E-004: Process Interceptor — Zero-Latency Detection
|
|
2
|
+
// Proves ARP's ProcessInterceptor catches child_process.spawn/exec BEFORE execution.
|
|
3
|
+
// Unlike the polling ProcessMonitor, this has zero detection latency and 100% accuracy.
|
|
4
|
+
//
|
|
5
|
+
// ATLAS: AML.T0046
|
|
6
|
+
// OWASP: A04
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import type { ChildProcess } from 'child_process';
|
|
10
|
+
import { ArpWrapper } from '../harness/arp-wrapper';
|
|
11
|
+
|
|
12
|
+
// Use require() to get the same CJS module the interceptor patches
|
|
13
|
+
// (ESM imports are snapshots and won't reflect interceptor patches)
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
+
const cp = require('child_process');
|
|
16
|
+
|
|
17
|
+
describe('E2E-004: Process Interceptor', () => {
|
|
18
|
+
let arp: ArpWrapper;
|
|
19
|
+
const children: ChildProcess[] = [];
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
arp = new ArpWrapper({
|
|
23
|
+
monitors: {
|
|
24
|
+
process: false,
|
|
25
|
+
network: false,
|
|
26
|
+
filesystem: false,
|
|
27
|
+
},
|
|
28
|
+
interceptors: {
|
|
29
|
+
process: true,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
await arp.start();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
for (const child of children) {
|
|
37
|
+
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
38
|
+
}
|
|
39
|
+
children.length = 0;
|
|
40
|
+
await arp.stop();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should intercept spawn() with zero latency', async () => {
|
|
44
|
+
const child = cp.spawn('echo', ['hello'], { stdio: 'ignore' });
|
|
45
|
+
children.push(child);
|
|
46
|
+
|
|
47
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
48
|
+
|
|
49
|
+
const events = arp.collector.getEvents();
|
|
50
|
+
const echoEvent = events.find(
|
|
51
|
+
(e: { source: string; data: Record<string, unknown> }) =>
|
|
52
|
+
e.source === 'process' && e.data.binary === 'echo',
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(echoEvent).toBeDefined();
|
|
56
|
+
expect(echoEvent!.data.intercepted).toBe(true);
|
|
57
|
+
expect(echoEvent!.data.command).toContain('echo');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should detect suspicious binary via interceptor', async () => {
|
|
61
|
+
const child = cp.spawn('curl', ['--version'], { stdio: 'ignore' });
|
|
62
|
+
children.push(child);
|
|
63
|
+
|
|
64
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
65
|
+
|
|
66
|
+
const events = arp.collector.getEvents();
|
|
67
|
+
const curlEvent = events.find(
|
|
68
|
+
(e: { source: string; category: string; data: Record<string, unknown> }) =>
|
|
69
|
+
e.source === 'process' && e.data.binary === 'curl',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(curlEvent).toBeDefined();
|
|
73
|
+
expect(curlEvent!.category).toBe('violation');
|
|
74
|
+
expect(curlEvent!.severity).toBe('high');
|
|
75
|
+
expect(curlEvent!.data.suspicious).toBe(true);
|
|
76
|
+
expect(curlEvent!.data.intercepted).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should intercept exec() shell commands', async () => {
|
|
80
|
+
const child = cp.exec('echo test-exec', () => {});
|
|
81
|
+
children.push(child);
|
|
82
|
+
|
|
83
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
84
|
+
|
|
85
|
+
const events = arp.collector.getEvents();
|
|
86
|
+
const execEvent = events.find(
|
|
87
|
+
(e: { source: string; data: Record<string, unknown> }) =>
|
|
88
|
+
e.source === 'process' &&
|
|
89
|
+
typeof e.data.command === 'string' &&
|
|
90
|
+
e.data.command.includes('echo test-exec'),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(execEvent).toBeDefined();
|
|
94
|
+
expect(execEvent!.data.intercepted).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should detect suspicious binary in exec() command', async () => {
|
|
98
|
+
const child = cp.exec('curl --version', () => {});
|
|
99
|
+
children.push(child);
|
|
100
|
+
|
|
101
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
102
|
+
|
|
103
|
+
const events = arp.collector.getEvents();
|
|
104
|
+
const curlEvent = events.find(
|
|
105
|
+
(e: { source: string; data: Record<string, unknown> }) =>
|
|
106
|
+
e.source === 'process' && e.data.binary === 'curl',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(curlEvent).toBeDefined();
|
|
110
|
+
expect(curlEvent!.category).toBe('violation');
|
|
111
|
+
expect(curlEvent!.severity).toBe('high');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should restore original functions after stop', async () => {
|
|
115
|
+
await arp.stop();
|
|
116
|
+
|
|
117
|
+
const child = cp.spawn('echo', ['after-stop'], { stdio: 'ignore' });
|
|
118
|
+
children.push(child);
|
|
119
|
+
|
|
120
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
121
|
+
|
|
122
|
+
const events = arp.collector.getEvents();
|
|
123
|
+
expect(events.length).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// E2E-005: Network Interceptor — Zero-Latency Connection Detection
|
|
2
|
+
// Proves ARP's NetworkInterceptor catches net.Socket.connect BEFORE connection.
|
|
3
|
+
// Unlike lsof/ss polling, this works everywhere and has zero detection latency.
|
|
4
|
+
//
|
|
5
|
+
// ATLAS: AML.T0024, AML.T0057
|
|
6
|
+
// OWASP: A04
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import * as net from 'net';
|
|
10
|
+
import { ArpWrapper } from '../harness/arp-wrapper';
|
|
11
|
+
|
|
12
|
+
describe('E2E-005: Network Interceptor', () => {
|
|
13
|
+
let arp: ArpWrapper;
|
|
14
|
+
let server: net.Server;
|
|
15
|
+
let serverPort: number;
|
|
16
|
+
let clientSocket: net.Socket | null = null;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
// Start a local TCP server
|
|
20
|
+
server = net.createServer((socket) => {
|
|
21
|
+
socket.on('data', (data) => socket.write(data));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await new Promise<void>((resolve) => {
|
|
25
|
+
server.listen(0, '127.0.0.1', () => {
|
|
26
|
+
const addr = server.address();
|
|
27
|
+
if (addr && typeof addr === 'object') {
|
|
28
|
+
serverPort = addr.port;
|
|
29
|
+
}
|
|
30
|
+
resolve();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
arp = new ArpWrapper({
|
|
35
|
+
monitors: {
|
|
36
|
+
process: false,
|
|
37
|
+
network: false, // Disable polling monitor
|
|
38
|
+
filesystem: false,
|
|
39
|
+
},
|
|
40
|
+
interceptors: {
|
|
41
|
+
network: true, // Enable interceptor
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
await arp.start();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
if (clientSocket) {
|
|
49
|
+
clientSocket.destroy();
|
|
50
|
+
clientSocket = null;
|
|
51
|
+
}
|
|
52
|
+
await arp.stop();
|
|
53
|
+
await new Promise<void>((resolve) => {
|
|
54
|
+
server.close(() => resolve());
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should intercept outbound TCP connection with zero latency', async () => {
|
|
59
|
+
clientSocket = net.connect({ host: '127.0.0.1', port: serverPort });
|
|
60
|
+
|
|
61
|
+
// Wait for connection event (should be nearly instant)
|
|
62
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
63
|
+
|
|
64
|
+
const events = arp.collector.getEvents();
|
|
65
|
+
const connEvent = events.find(
|
|
66
|
+
(e) =>
|
|
67
|
+
e.source === 'network' &&
|
|
68
|
+
e.data.remotePort === serverPort,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(connEvent).toBeDefined();
|
|
72
|
+
expect(connEvent!.data.intercepted).toBe(true);
|
|
73
|
+
expect(connEvent!.data.remoteAddr).toBe('127.0.0.1');
|
|
74
|
+
expect(connEvent!.data.remotePort).toBe(serverPort);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should classify connections to allowed hosts as normal', async () => {
|
|
78
|
+
// Restart with allowed hosts
|
|
79
|
+
await arp.stop();
|
|
80
|
+
arp = new ArpWrapper({
|
|
81
|
+
monitors: { process: false, network: false, filesystem: false },
|
|
82
|
+
interceptors: { network: true },
|
|
83
|
+
interceptorNetworkAllowedHosts: ['127.0.0.1'],
|
|
84
|
+
});
|
|
85
|
+
await arp.start();
|
|
86
|
+
|
|
87
|
+
clientSocket = net.connect({ host: '127.0.0.1', port: serverPort });
|
|
88
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
89
|
+
|
|
90
|
+
const events = arp.collector.getEvents();
|
|
91
|
+
const connEvent = events.find(
|
|
92
|
+
(e) => e.source === 'network' && e.data.remotePort === serverPort,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(connEvent).toBeDefined();
|
|
96
|
+
expect(connEvent!.category).toBe('normal');
|
|
97
|
+
expect(connEvent!.data.allowed).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should classify connections to non-allowed hosts as anomaly', async () => {
|
|
101
|
+
// Restart with restricted allowed hosts (not including 127.0.0.1)
|
|
102
|
+
await arp.stop();
|
|
103
|
+
arp = new ArpWrapper({
|
|
104
|
+
monitors: { process: false, network: false, filesystem: false },
|
|
105
|
+
interceptors: { network: true },
|
|
106
|
+
interceptorNetworkAllowedHosts: ['api.example.com'],
|
|
107
|
+
});
|
|
108
|
+
await arp.start();
|
|
109
|
+
|
|
110
|
+
clientSocket = net.connect({ host: '127.0.0.1', port: serverPort });
|
|
111
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
112
|
+
|
|
113
|
+
const events = arp.collector.getEvents();
|
|
114
|
+
const connEvent = events.find(
|
|
115
|
+
(e) => e.source === 'network' && e.data.remotePort === serverPort,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(connEvent).toBeDefined();
|
|
119
|
+
expect(connEvent!.category).toBe('anomaly');
|
|
120
|
+
expect(connEvent!.severity).toBe('medium');
|
|
121
|
+
expect(connEvent!.data.allowed).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should restore net.Socket after stop', async () => {
|
|
125
|
+
await arp.stop();
|
|
126
|
+
|
|
127
|
+
// After stop, connections should not generate events
|
|
128
|
+
clientSocket = net.connect({ host: '127.0.0.1', port: serverPort });
|
|
129
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
130
|
+
|
|
131
|
+
const events = arp.collector.getEvents();
|
|
132
|
+
expect(events.length).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// E2E-006: Filesystem Interceptor — Zero-Latency File Operation Detection
|
|
2
|
+
// Proves ARP's FilesystemInterceptor catches fs.readFile/writeFile BEFORE I/O.
|
|
3
|
+
// Unlike fs.watch, this catches reads, works on ALL paths, and has zero latency.
|
|
4
|
+
//
|
|
5
|
+
// ATLAS: AML.T0057, AML.T0018
|
|
6
|
+
// OWASP: A07
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import { ArpWrapper } from '../harness/arp-wrapper';
|
|
12
|
+
|
|
13
|
+
// Use require() to get the same CJS module the interceptor patches
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
describe('E2E-006: Filesystem Interceptor', () => {
|
|
18
|
+
let arp: ArpWrapper;
|
|
19
|
+
let tmpDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'arp-e2e-fsi-'));
|
|
23
|
+
|
|
24
|
+
arp = new ArpWrapper({
|
|
25
|
+
monitors: {
|
|
26
|
+
process: false,
|
|
27
|
+
network: false,
|
|
28
|
+
filesystem: false,
|
|
29
|
+
},
|
|
30
|
+
interceptors: {
|
|
31
|
+
filesystem: true,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
await arp.start();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await arp.stop();
|
|
39
|
+
try {
|
|
40
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
41
|
+
} catch {
|
|
42
|
+
// best effort
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should intercept writeFileSync to sensitive .env path', async () => {
|
|
47
|
+
const envPath = path.join(tmpDir, '.env');
|
|
48
|
+
fs.writeFileSync(envPath, 'SECRET=intercepted\n');
|
|
49
|
+
|
|
50
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
51
|
+
|
|
52
|
+
const events = arp.collector.getEvents();
|
|
53
|
+
const writeEvent = events.find(
|
|
54
|
+
(e: { source: string; data: Record<string, unknown> }) =>
|
|
55
|
+
e.source === 'filesystem' &&
|
|
56
|
+
e.data.intercepted === true &&
|
|
57
|
+
String(e.data.path).includes('.env'),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(writeEvent).toBeDefined();
|
|
61
|
+
expect(writeEvent!.category).toBe('violation');
|
|
62
|
+
expect(writeEvent!.severity).toBe('high');
|
|
63
|
+
expect(writeEvent!.data.sensitive).toBe(true);
|
|
64
|
+
expect(writeEvent!.data.operation).toBe('writeFileSync');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should intercept readFileSync on sensitive .ssh path', async () => {
|
|
68
|
+
const sshPath = path.join(tmpDir, '.ssh');
|
|
69
|
+
fs.mkdirSync(sshPath, { recursive: true });
|
|
70
|
+
const keyPath = path.join(sshPath, 'id_rsa');
|
|
71
|
+
fs.writeFileSync(keyPath, 'fake-key\n');
|
|
72
|
+
|
|
73
|
+
// Now read it
|
|
74
|
+
fs.readFileSync(keyPath, 'utf-8');
|
|
75
|
+
|
|
76
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
77
|
+
|
|
78
|
+
const events = arp.collector.getEvents();
|
|
79
|
+
const readEvent = events.find(
|
|
80
|
+
(e: { source: string; data: Record<string, unknown> }) =>
|
|
81
|
+
e.source === 'filesystem' &&
|
|
82
|
+
e.data.operation === 'read' &&
|
|
83
|
+
String(e.data.path).includes('.ssh'),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(readEvent).toBeDefined();
|
|
87
|
+
expect(readEvent!.category).toBe('violation');
|
|
88
|
+
expect(readEvent!.severity).toBe('high');
|
|
89
|
+
expect(readEvent!.data.sensitive).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should intercept normal file writes without marking as violation', async () => {
|
|
93
|
+
const normalPath = path.join(tmpDir, 'output.json');
|
|
94
|
+
fs.writeFileSync(normalPath, '{"ok": true}\n');
|
|
95
|
+
|
|
96
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
97
|
+
|
|
98
|
+
const events = arp.collector.getEvents();
|
|
99
|
+
const writeEvent = events.find(
|
|
100
|
+
(e: { source: string; data: Record<string, unknown> }) =>
|
|
101
|
+
e.source === 'filesystem' &&
|
|
102
|
+
e.data.intercepted === true &&
|
|
103
|
+
String(e.data.path).includes('output.json'),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(writeEvent).toBeDefined();
|
|
107
|
+
expect(writeEvent!.category).toBe('normal');
|
|
108
|
+
expect(writeEvent!.severity).toBe('info');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should intercept .bashrc write as persistence attempt', async () => {
|
|
112
|
+
const bashrcPath = path.join(tmpDir, '.bashrc');
|
|
113
|
+
fs.writeFileSync(bashrcPath, 'alias x="malicious"\n');
|
|
114
|
+
|
|
115
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
116
|
+
|
|
117
|
+
const events = arp.collector.getEvents();
|
|
118
|
+
const bashrcEvent = events.find(
|
|
119
|
+
(e: { source: string; data: Record<string, unknown> }) =>
|
|
120
|
+
e.source === 'filesystem' &&
|
|
121
|
+
String(e.data.path).includes('.bashrc'),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(bashrcEvent).toBeDefined();
|
|
125
|
+
expect(bashrcEvent!.category).toBe('violation');
|
|
126
|
+
expect(bashrcEvent!.severity).toBe('high');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should restore fs module after stop', async () => {
|
|
130
|
+
await arp.stop();
|
|
131
|
+
|
|
132
|
+
const normalPath = path.join(tmpDir, 'after-stop.txt');
|
|
133
|
+
fs.writeFileSync(normalPath, 'no events\n');
|
|
134
|
+
|
|
135
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
136
|
+
|
|
137
|
+
const events = arp.collector.getEvents();
|
|
138
|
+
expect(events.length).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
AgentRuntimeProtection,
|
|
6
|
+
EventEngine,
|
|
7
|
+
EnforcementEngine,
|
|
8
|
+
type ARPConfig,
|
|
9
|
+
type ARPEvent,
|
|
10
|
+
} from '@opena2a/arp';
|
|
11
|
+
import { EventCollector } from './event-collector';
|
|
12
|
+
import type { LabConfig } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wraps AgentRuntimeProtection for controlled testing.
|
|
16
|
+
* Creates temp dataDir per test, registers EventCollector,
|
|
17
|
+
* and provides injection + assertion helpers.
|
|
18
|
+
*/
|
|
19
|
+
export class ArpWrapper {
|
|
20
|
+
private arp: AgentRuntimeProtection;
|
|
21
|
+
private _dataDir: string;
|
|
22
|
+
readonly collector: EventCollector;
|
|
23
|
+
|
|
24
|
+
constructor(labConfig?: LabConfig) {
|
|
25
|
+
this._dataDir = labConfig?.dataDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'arp-lab-'));
|
|
26
|
+
|
|
27
|
+
const config: ARPConfig = {
|
|
28
|
+
agentName: 'arp-lab-target',
|
|
29
|
+
agentDescription: 'Test target for ARP security lab',
|
|
30
|
+
declaredCapabilities: ['file read/write', 'HTTP requests'],
|
|
31
|
+
dataDir: this._dataDir,
|
|
32
|
+
monitors: {
|
|
33
|
+
process: {
|
|
34
|
+
enabled: labConfig?.monitors?.process ?? false,
|
|
35
|
+
intervalMs: labConfig?.processIntervalMs,
|
|
36
|
+
},
|
|
37
|
+
network: {
|
|
38
|
+
enabled: labConfig?.monitors?.network ?? false,
|
|
39
|
+
intervalMs: labConfig?.networkIntervalMs,
|
|
40
|
+
allowedHosts: labConfig?.networkAllowedHosts,
|
|
41
|
+
},
|
|
42
|
+
filesystem: {
|
|
43
|
+
enabled: labConfig?.monitors?.filesystem ?? false,
|
|
44
|
+
watchPaths: labConfig?.filesystemWatchPaths,
|
|
45
|
+
allowedPaths: labConfig?.filesystemAllowedPaths,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
rules: labConfig?.rules,
|
|
49
|
+
intelligence: {
|
|
50
|
+
enabled: labConfig?.intelligence?.enabled ?? false,
|
|
51
|
+
budgetUsd: 0,
|
|
52
|
+
},
|
|
53
|
+
interceptors: {
|
|
54
|
+
process: { enabled: labConfig?.interceptors?.process ?? false },
|
|
55
|
+
network: {
|
|
56
|
+
enabled: labConfig?.interceptors?.network ?? false,
|
|
57
|
+
allowedHosts: labConfig?.interceptorNetworkAllowedHosts,
|
|
58
|
+
},
|
|
59
|
+
filesystem: {
|
|
60
|
+
enabled: labConfig?.interceptors?.filesystem ?? false,
|
|
61
|
+
allowedPaths: labConfig?.interceptorFilesystemAllowedPaths,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
this.arp = new AgentRuntimeProtection(config);
|
|
67
|
+
this.collector = new EventCollector();
|
|
68
|
+
|
|
69
|
+
// Register event and enforcement collectors
|
|
70
|
+
this.arp.onEvent(this.collector.eventHandler);
|
|
71
|
+
this.arp.onEnforcement(this.collector.enforcementHandler);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async start(): Promise<void> {
|
|
75
|
+
await this.arp.start();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async stop(): Promise<void> {
|
|
79
|
+
await this.arp.stop();
|
|
80
|
+
this.collector.reset();
|
|
81
|
+
// Clean up temp dir
|
|
82
|
+
try {
|
|
83
|
+
fs.rmSync(this._dataDir, { recursive: true, force: true });
|
|
84
|
+
} catch {
|
|
85
|
+
// Best effort cleanup
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Get the underlying ARP instance */
|
|
90
|
+
getInstance(): AgentRuntimeProtection {
|
|
91
|
+
return this.arp;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Get the event engine for direct event injection */
|
|
95
|
+
getEngine(): EventEngine {
|
|
96
|
+
return this.arp.getEngine();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Get the enforcement engine */
|
|
100
|
+
getEnforcement(): EnforcementEngine {
|
|
101
|
+
return this.arp.getEnforcement();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Inject a synthetic event into the ARP engine (for testing without real OS activity) */
|
|
105
|
+
async injectEvent(event: Omit<ARPEvent, 'id' | 'timestamp' | 'classifiedBy'>): Promise<ARPEvent> {
|
|
106
|
+
return this.getEngine().emit(event);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Wait for an event matching a predicate */
|
|
110
|
+
waitForEvent(
|
|
111
|
+
predicate: (event: ARPEvent) => boolean,
|
|
112
|
+
timeoutMs: number = 10000,
|
|
113
|
+
): Promise<ARPEvent> {
|
|
114
|
+
return this.collector.waitForEvent(predicate, timeoutMs);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Get the data directory */
|
|
118
|
+
get dataDir(): string {
|
|
119
|
+
return this._dataDir;
|
|
120
|
+
}
|
|
121
|
+
}
|