@panguard-ai/panguard-guard 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/dist/agent/analyze-agent.d.ts +62 -0
- package/dist/agent/analyze-agent.d.ts.map +1 -0
- package/dist/agent/analyze-agent.js +327 -0
- package/dist/agent/analyze-agent.js.map +1 -0
- package/dist/agent/detect-agent.d.ts +59 -0
- package/dist/agent/detect-agent.d.ts.map +1 -0
- package/dist/agent/detect-agent.js +214 -0
- package/dist/agent/detect-agent.js.map +1 -0
- package/dist/agent/index.d.ts +15 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +14 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/report-agent.d.ts +122 -0
- package/dist/agent/report-agent.d.ts.map +1 -0
- package/dist/agent/report-agent.js +468 -0
- package/dist/agent/report-agent.js.map +1 -0
- package/dist/agent/respond-agent.d.ts +113 -0
- package/dist/agent/respond-agent.d.ts.map +1 -0
- package/dist/agent/respond-agent.js +749 -0
- package/dist/agent/respond-agent.js.map +1 -0
- package/dist/agent-client/index.d.ts +81 -0
- package/dist/agent-client/index.d.ts.map +1 -0
- package/dist/agent-client/index.js +170 -0
- package/dist/agent-client/index.js.map +1 -0
- package/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +295 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +108 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon/index.d.ts +66 -0
- package/dist/daemon/index.d.ts.map +1 -0
- package/dist/daemon/index.js +284 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/dashboard/index.d.ts +78 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +455 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/guard-engine.d.ts +108 -0
- package/dist/guard-engine.d.ts.map +1 -0
- package/dist/guard-engine.js +740 -0
- package/dist/guard-engine.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/install/index.d.ts +23 -0
- package/dist/install/index.d.ts.map +1 -0
- package/dist/install/index.js +216 -0
- package/dist/install/index.js.map +1 -0
- package/dist/investigation/index.d.ts +80 -0
- package/dist/investigation/index.d.ts.map +1 -0
- package/dist/investigation/index.js +570 -0
- package/dist/investigation/index.js.map +1 -0
- package/dist/license/index.d.ts +46 -0
- package/dist/license/index.d.ts.map +1 -0
- package/dist/license/index.js +145 -0
- package/dist/license/index.js.map +1 -0
- package/dist/memory/baseline.d.ts +34 -0
- package/dist/memory/baseline.d.ts.map +1 -0
- package/dist/memory/baseline.js +224 -0
- package/dist/memory/baseline.js.map +1 -0
- package/dist/memory/index.d.ts +32 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +58 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/memory/learning.d.ts +35 -0
- package/dist/memory/learning.d.ts.map +1 -0
- package/dist/memory/learning.js +60 -0
- package/dist/memory/learning.js.map +1 -0
- package/dist/monitors/falco-monitor.d.ts +62 -0
- package/dist/monitors/falco-monitor.d.ts.map +1 -0
- package/dist/monitors/falco-monitor.js +226 -0
- package/dist/monitors/falco-monitor.js.map +1 -0
- package/dist/monitors/suricata-monitor.d.ts +80 -0
- package/dist/monitors/suricata-monitor.d.ts.map +1 -0
- package/dist/monitors/suricata-monitor.js +227 -0
- package/dist/monitors/suricata-monitor.js.map +1 -0
- package/dist/notify/email.d.ts +23 -0
- package/dist/notify/email.d.ts.map +1 -0
- package/dist/notify/email.js +124 -0
- package/dist/notify/email.js.map +1 -0
- package/dist/notify/index.d.ts +31 -0
- package/dist/notify/index.d.ts.map +1 -0
- package/dist/notify/index.js +70 -0
- package/dist/notify/index.js.map +1 -0
- package/dist/notify/line-notify.d.ts.map +1 -0
- package/dist/notify/slack.d.ts +21 -0
- package/dist/notify/slack.d.ts.map +1 -0
- package/dist/notify/slack.js +92 -0
- package/dist/notify/slack.js.map +1 -0
- package/dist/notify/telegram.d.ts +21 -0
- package/dist/notify/telegram.d.ts.map +1 -0
- package/dist/notify/telegram.js +89 -0
- package/dist/notify/telegram.js.map +1 -0
- package/dist/response/file-quarantine.d.ts +63 -0
- package/dist/response/file-quarantine.d.ts.map +1 -0
- package/dist/response/file-quarantine.js +137 -0
- package/dist/response/file-quarantine.js.map +1 -0
- package/dist/response/index.d.ts +4 -0
- package/dist/response/index.d.ts.map +1 -0
- package/dist/response/index.js +4 -0
- package/dist/response/index.js.map +1 -0
- package/dist/response/ip-blocker.d.ts +69 -0
- package/dist/response/ip-blocker.d.ts.map +1 -0
- package/dist/response/ip-blocker.js +191 -0
- package/dist/response/ip-blocker.js.map +1 -0
- package/dist/response/process-killer.d.ts +49 -0
- package/dist/response/process-killer.d.ts.map +1 -0
- package/dist/response/process-killer.js +230 -0
- package/dist/response/process-killer.js.map +1 -0
- package/dist/rules/builtin-rules.d.ts +12 -0
- package/dist/rules/builtin-rules.d.ts.map +1 -0
- package/dist/rules/builtin-rules.js +471 -0
- package/dist/rules/builtin-rules.js.map +1 -0
- package/dist/threat-cloud/client-id.d.ts +13 -0
- package/dist/threat-cloud/client-id.d.ts.map +1 -0
- package/dist/threat-cloud/client-id.js +38 -0
- package/dist/threat-cloud/client-id.js.map +1 -0
- package/dist/threat-cloud/index.d.ts +103 -0
- package/dist/threat-cloud/index.d.ts.map +1 -0
- package/dist/threat-cloud/index.js +386 -0
- package/dist/threat-cloud/index.js.map +1 -0
- package/dist/types.d.ts +336 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +42 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Respond Agent - Execute response actions with persistence, rollback, and escalation
|
|
3
|
+
* 回應代理 - 執行回應動作,支援持久化、回滾和漸進升級
|
|
4
|
+
*
|
|
5
|
+
* Third stage of the multi-agent pipeline. Determines and executes
|
|
6
|
+
* the appropriate response action based on verdict confidence levels
|
|
7
|
+
* and the configured action policy thresholds.
|
|
8
|
+
*
|
|
9
|
+
* Uses execFile (never exec) for all system commands to prevent
|
|
10
|
+
* command injection vulnerabilities.
|
|
11
|
+
*
|
|
12
|
+
* @module @panguard-ai/panguard-guard/agent/respond-agent
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import { platform } from 'node:os';
|
|
16
|
+
import { appendFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import { createLogger } from '@panguard-ai/core';
|
|
19
|
+
const logger = createLogger('panguard-guard:respond-agent');
|
|
20
|
+
/**
|
|
21
|
+
* Safety rules for auto-response actions
|
|
22
|
+
*/
|
|
23
|
+
const SAFETY_RULES = {
|
|
24
|
+
whitelistedIPs: new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']),
|
|
25
|
+
protectedProcesses: new Set([
|
|
26
|
+
'sshd',
|
|
27
|
+
'systemd',
|
|
28
|
+
'init',
|
|
29
|
+
'launchd',
|
|
30
|
+
'loginwindow',
|
|
31
|
+
'explorer.exe',
|
|
32
|
+
'svchost.exe',
|
|
33
|
+
'csrss.exe',
|
|
34
|
+
'lsass.exe',
|
|
35
|
+
'services.exe',
|
|
36
|
+
'winlogon.exe',
|
|
37
|
+
'wininit.exe',
|
|
38
|
+
'panguard-guard',
|
|
39
|
+
'node',
|
|
40
|
+
]),
|
|
41
|
+
protectedAccounts: new Set(['root', 'Administrator', 'admin', 'SYSTEM', 'LocalSystem']),
|
|
42
|
+
/** Default auto-unblock duration: 1 hour */
|
|
43
|
+
defaultBlockDurationMs: 60 * 60 * 1000,
|
|
44
|
+
/** Extended block duration for repeat offenders: 24 hours */
|
|
45
|
+
repeatOffenderBlockDurationMs: 24 * 60 * 60 * 1000,
|
|
46
|
+
/** SIGKILL timeout after SIGTERM: 5 seconds */
|
|
47
|
+
sigkillTimeoutMs: 5000,
|
|
48
|
+
/** Network isolation requires confidence >= 95 */
|
|
49
|
+
networkIsolationMinConfidence: 95,
|
|
50
|
+
/** Violations before escalation */
|
|
51
|
+
escalationThreshold: 3,
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Respond Agent determines and executes appropriate response actions
|
|
55
|
+
* with persistence, auto-unblock timers, SIGKILL fallback, and escalation.
|
|
56
|
+
*/
|
|
57
|
+
export class RespondAgent {
|
|
58
|
+
actionPolicy;
|
|
59
|
+
mode;
|
|
60
|
+
actionCount = 0;
|
|
61
|
+
additionalWhitelistedIPs;
|
|
62
|
+
/** Action manifest for persistence and rollback */
|
|
63
|
+
manifest = [];
|
|
64
|
+
manifestPath;
|
|
65
|
+
/** Escalation tracker: target → record */
|
|
66
|
+
escalationMap = new Map();
|
|
67
|
+
/** Active unblock timers */
|
|
68
|
+
unblockTimers = new Map();
|
|
69
|
+
constructor(actionPolicy, mode, whitelistedIPs = [], dataDir = '/var/panguard-guard') {
|
|
70
|
+
this.actionPolicy = actionPolicy;
|
|
71
|
+
this.mode = mode;
|
|
72
|
+
this.additionalWhitelistedIPs = new Set(whitelistedIPs);
|
|
73
|
+
this.manifestPath = `${dataDir}/action-manifest.jsonl`;
|
|
74
|
+
// Ensure manifest directory exists
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dirname(this.manifestPath), { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Directory may already exist
|
|
80
|
+
}
|
|
81
|
+
// Load existing manifest on startup
|
|
82
|
+
this.loadManifest();
|
|
83
|
+
}
|
|
84
|
+
/** Update operating mode */
|
|
85
|
+
setMode(mode) {
|
|
86
|
+
this.mode = mode;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Execute response based on verdict with escalation awareness
|
|
90
|
+
*/
|
|
91
|
+
async respond(verdict) {
|
|
92
|
+
// Learning mode: never take active response
|
|
93
|
+
if (this.mode === 'learning') {
|
|
94
|
+
logger.info('Learning mode: no response action taken');
|
|
95
|
+
return {
|
|
96
|
+
action: 'log_only',
|
|
97
|
+
success: true,
|
|
98
|
+
details: 'Learning mode - observation only',
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const { confidence } = verdict;
|
|
103
|
+
// Check escalation: repeat offenders get lower thresholds
|
|
104
|
+
const target = this.extractTarget(verdict);
|
|
105
|
+
const escalation = target ? this.escalationMap.get(target) : undefined;
|
|
106
|
+
const isRepeatOffender = escalation && escalation.violationCount >= SAFETY_RULES.escalationThreshold;
|
|
107
|
+
// Repeat offenders: lower auto-respond threshold by 10%
|
|
108
|
+
const effectiveAutoRespond = isRepeatOffender
|
|
109
|
+
? Math.max(50, this.actionPolicy.autoRespond - 10)
|
|
110
|
+
: this.actionPolicy.autoRespond;
|
|
111
|
+
// Auto-respond: execute the recommended action
|
|
112
|
+
if (confidence >= effectiveAutoRespond) {
|
|
113
|
+
if (isRepeatOffender) {
|
|
114
|
+
logger.warn(`Repeat offender ${target}: auto-responding at lower threshold ` +
|
|
115
|
+
`(${effectiveAutoRespond}% instead of ${this.actionPolicy.autoRespond}%)`);
|
|
116
|
+
}
|
|
117
|
+
// Track escalation
|
|
118
|
+
if (target)
|
|
119
|
+
this.trackEscalation(target);
|
|
120
|
+
return this.executeAction(verdict.recommendedAction, verdict);
|
|
121
|
+
}
|
|
122
|
+
// Notify: send alert but do not auto-execute
|
|
123
|
+
if (confidence >= this.actionPolicy.notifyAndWait) {
|
|
124
|
+
// Track escalation even for notify-level events
|
|
125
|
+
if (target)
|
|
126
|
+
this.trackEscalation(target);
|
|
127
|
+
return {
|
|
128
|
+
action: 'notify',
|
|
129
|
+
success: true,
|
|
130
|
+
details: `Notification sent. Verdict: ${verdict.conclusion}, ` +
|
|
131
|
+
`recommended: ${verdict.recommendedAction}`,
|
|
132
|
+
timestamp: new Date().toISOString(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// Log only
|
|
136
|
+
return {
|
|
137
|
+
action: 'log_only',
|
|
138
|
+
success: true,
|
|
139
|
+
details: `Logged: ${verdict.conclusion} (confidence: ${confidence}%)`,
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Rollback a previous action by manifest entry ID
|
|
145
|
+
*/
|
|
146
|
+
async rollback(entryId) {
|
|
147
|
+
const entry = this.manifest.find((e) => e.id === entryId && !e.rolledBack);
|
|
148
|
+
if (!entry) {
|
|
149
|
+
return {
|
|
150
|
+
action: 'log_only',
|
|
151
|
+
success: false,
|
|
152
|
+
details: `No rollback-able action found for ID ${entryId}`,
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
let result;
|
|
157
|
+
switch (entry.action) {
|
|
158
|
+
case 'block_ip':
|
|
159
|
+
result = await this.unblockIP(entry.target);
|
|
160
|
+
break;
|
|
161
|
+
case 'isolate_file':
|
|
162
|
+
result = {
|
|
163
|
+
action: 'isolate_file',
|
|
164
|
+
success: false,
|
|
165
|
+
details: 'File restore requires manual intervention. Check quarantine directory.',
|
|
166
|
+
timestamp: new Date().toISOString(),
|
|
167
|
+
target: entry.target,
|
|
168
|
+
};
|
|
169
|
+
break;
|
|
170
|
+
default:
|
|
171
|
+
result = {
|
|
172
|
+
action: entry.action,
|
|
173
|
+
success: false,
|
|
174
|
+
details: `Rollback not supported for action: ${entry.action}`,
|
|
175
|
+
timestamp: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (result.success) {
|
|
179
|
+
entry.rolledBack = true;
|
|
180
|
+
this.persistManifestEntry(entry);
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Get all active (non-rolled-back) actions
|
|
186
|
+
*/
|
|
187
|
+
getActiveActions() {
|
|
188
|
+
return this.manifest.filter((e) => !e.rolledBack);
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Action execution
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
async executeAction(action, verdict) {
|
|
194
|
+
this.actionCount++;
|
|
195
|
+
switch (action) {
|
|
196
|
+
case 'block_ip':
|
|
197
|
+
return this.blockIP(verdict);
|
|
198
|
+
case 'kill_process':
|
|
199
|
+
return this.killProcess(verdict);
|
|
200
|
+
case 'disable_account':
|
|
201
|
+
return this.disableAccount(verdict);
|
|
202
|
+
case 'isolate_file':
|
|
203
|
+
return this.isolateFile(verdict);
|
|
204
|
+
case 'notify':
|
|
205
|
+
return {
|
|
206
|
+
action: 'notify',
|
|
207
|
+
success: true,
|
|
208
|
+
details: 'Notification dispatched',
|
|
209
|
+
timestamp: new Date().toISOString(),
|
|
210
|
+
};
|
|
211
|
+
default:
|
|
212
|
+
return {
|
|
213
|
+
action: 'log_only',
|
|
214
|
+
success: true,
|
|
215
|
+
details: 'Action logged',
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Block an IP address with auto-unblock timer
|
|
222
|
+
*/
|
|
223
|
+
async blockIP(verdict) {
|
|
224
|
+
const ip = this.extractIP(verdict);
|
|
225
|
+
if (!ip) {
|
|
226
|
+
return {
|
|
227
|
+
action: 'block_ip',
|
|
228
|
+
success: false,
|
|
229
|
+
details: 'No IP address found in verdict evidence',
|
|
230
|
+
timestamp: new Date().toISOString(),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
// Safety: check whitelisted IPs
|
|
234
|
+
if (SAFETY_RULES.whitelistedIPs.has(ip) || this.additionalWhitelistedIPs.has(ip)) {
|
|
235
|
+
logger.warn(`Refusing to block whitelisted IP: ${ip}`);
|
|
236
|
+
return {
|
|
237
|
+
action: 'block_ip',
|
|
238
|
+
success: false,
|
|
239
|
+
details: `IP ${ip} is whitelisted and cannot be blocked`,
|
|
240
|
+
timestamp: new Date().toISOString(),
|
|
241
|
+
target: ip,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Validate IP format (IPv4 or IPv6)
|
|
245
|
+
if (!/^[\d.]+$/.test(ip) && !/^[a-fA-F\d:]+$/.test(ip)) {
|
|
246
|
+
return {
|
|
247
|
+
action: 'block_ip',
|
|
248
|
+
success: false,
|
|
249
|
+
details: `Invalid IP format: ${ip}`,
|
|
250
|
+
timestamp: new Date().toISOString(),
|
|
251
|
+
target: ip,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// Determine block duration based on repeat offender status
|
|
255
|
+
const escalation = this.escalationMap.get(ip);
|
|
256
|
+
const isRepeat = escalation && escalation.violationCount >= SAFETY_RULES.escalationThreshold;
|
|
257
|
+
const blockDuration = isRepeat
|
|
258
|
+
? SAFETY_RULES.repeatOffenderBlockDurationMs
|
|
259
|
+
: SAFETY_RULES.defaultBlockDurationMs;
|
|
260
|
+
const os = platform();
|
|
261
|
+
try {
|
|
262
|
+
if (os === 'darwin') {
|
|
263
|
+
await execFilePromise('/sbin/pfctl', ['-t', 'panguard-guard_blocked', '-T', 'add', ip]);
|
|
264
|
+
}
|
|
265
|
+
else if (os === 'linux') {
|
|
266
|
+
await execFilePromise('/sbin/iptables', ['-A', 'INPUT', '-s', ip, '-j', 'DROP']);
|
|
267
|
+
}
|
|
268
|
+
else if (os === 'win32') {
|
|
269
|
+
await execFilePromise('netsh', [
|
|
270
|
+
'advfirewall',
|
|
271
|
+
'firewall',
|
|
272
|
+
'add',
|
|
273
|
+
'rule',
|
|
274
|
+
`name=PanguardGuard_Block_${ip}`,
|
|
275
|
+
'dir=in',
|
|
276
|
+
'action=block',
|
|
277
|
+
`remoteip=${ip}`,
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
const expiresAt = new Date(Date.now() + blockDuration).toISOString();
|
|
281
|
+
// Persist action to manifest
|
|
282
|
+
const entry = this.recordAction('block_ip', ip, verdict, expiresAt);
|
|
283
|
+
// Set auto-unblock timer
|
|
284
|
+
this.scheduleUnblock(ip, blockDuration, entry.id);
|
|
285
|
+
const durationStr = isRepeat ? '24h (repeat offender)' : '1h';
|
|
286
|
+
logger.info(`Blocked IP: ${ip} for ${durationStr} (auto-unblock scheduled)`);
|
|
287
|
+
return {
|
|
288
|
+
action: 'block_ip',
|
|
289
|
+
success: true,
|
|
290
|
+
details: `IP ${ip} blocked via ${os} firewall for ${durationStr}. Auto-unblock at ${expiresAt}`,
|
|
291
|
+
timestamp: new Date().toISOString(),
|
|
292
|
+
target: ip,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
297
|
+
logger.error(`Failed to block IP ${ip}: ${msg}`);
|
|
298
|
+
return {
|
|
299
|
+
action: 'block_ip',
|
|
300
|
+
success: false,
|
|
301
|
+
details: `Failed to block IP ${ip}: ${msg}`,
|
|
302
|
+
timestamp: new Date().toISOString(),
|
|
303
|
+
target: ip,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Unblock a previously blocked IP
|
|
309
|
+
*/
|
|
310
|
+
async unblockIP(ip) {
|
|
311
|
+
const os = platform();
|
|
312
|
+
try {
|
|
313
|
+
if (os === 'darwin') {
|
|
314
|
+
await execFilePromise('/sbin/pfctl', ['-t', 'panguard-guard_blocked', '-T', 'delete', ip]);
|
|
315
|
+
}
|
|
316
|
+
else if (os === 'linux') {
|
|
317
|
+
await execFilePromise('/sbin/iptables', ['-D', 'INPUT', '-s', ip, '-j', 'DROP']);
|
|
318
|
+
}
|
|
319
|
+
else if (os === 'win32') {
|
|
320
|
+
await execFilePromise('netsh', [
|
|
321
|
+
'advfirewall',
|
|
322
|
+
'firewall',
|
|
323
|
+
'delete',
|
|
324
|
+
'rule',
|
|
325
|
+
`name=PanguardGuard_Block_${ip}`,
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
// Clear the unblock timer
|
|
329
|
+
const timer = this.unblockTimers.get(ip);
|
|
330
|
+
if (timer) {
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
this.unblockTimers.delete(ip);
|
|
333
|
+
}
|
|
334
|
+
logger.info(`Unblocked IP: ${ip}`);
|
|
335
|
+
return {
|
|
336
|
+
action: 'block_ip',
|
|
337
|
+
success: true,
|
|
338
|
+
details: `IP ${ip} unblocked`,
|
|
339
|
+
timestamp: new Date().toISOString(),
|
|
340
|
+
target: ip,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
345
|
+
logger.error(`Failed to unblock IP ${ip}: ${msg}`);
|
|
346
|
+
return {
|
|
347
|
+
action: 'block_ip',
|
|
348
|
+
success: false,
|
|
349
|
+
details: `Failed to unblock IP ${ip}: ${msg}`,
|
|
350
|
+
timestamp: new Date().toISOString(),
|
|
351
|
+
target: ip,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Schedule auto-unblock after duration
|
|
357
|
+
*/
|
|
358
|
+
scheduleUnblock(ip, durationMs, entryId) {
|
|
359
|
+
// Clear existing timer for this IP
|
|
360
|
+
const existing = this.unblockTimers.get(ip);
|
|
361
|
+
if (existing)
|
|
362
|
+
clearTimeout(existing);
|
|
363
|
+
const timer = setTimeout(async () => {
|
|
364
|
+
logger.info(`Auto-unblock timer expired for IP: ${ip}`);
|
|
365
|
+
const result = await this.unblockIP(ip);
|
|
366
|
+
if (result.success) {
|
|
367
|
+
const entry = this.manifest.find((e) => e.id === entryId);
|
|
368
|
+
if (entry) {
|
|
369
|
+
entry.rolledBack = true;
|
|
370
|
+
this.persistManifestEntry(entry);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
this.unblockTimers.delete(ip);
|
|
374
|
+
}, durationMs);
|
|
375
|
+
// Don't hold the process open for unblock timers
|
|
376
|
+
if (timer.unref)
|
|
377
|
+
timer.unref();
|
|
378
|
+
this.unblockTimers.set(ip, timer);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Kill a process with SIGKILL fallback
|
|
382
|
+
*/
|
|
383
|
+
async killProcess(verdict) {
|
|
384
|
+
const pid = this.extractPID(verdict);
|
|
385
|
+
if (!pid) {
|
|
386
|
+
return {
|
|
387
|
+
action: 'kill_process',
|
|
388
|
+
success: false,
|
|
389
|
+
details: 'No PID found in verdict evidence',
|
|
390
|
+
timestamp: new Date().toISOString(),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// Safety: check protected processes
|
|
394
|
+
const processName = this.extractProcessName(verdict);
|
|
395
|
+
if (processName && SAFETY_RULES.protectedProcesses.has(processName)) {
|
|
396
|
+
logger.warn(`Refusing to kill protected process: ${processName} (PID ${pid})`);
|
|
397
|
+
return {
|
|
398
|
+
action: 'kill_process',
|
|
399
|
+
success: false,
|
|
400
|
+
details: `Process ${processName} is protected and cannot be killed`,
|
|
401
|
+
timestamp: new Date().toISOString(),
|
|
402
|
+
target: String(pid),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// Safety: never kill our own process
|
|
406
|
+
if (pid === process.pid) {
|
|
407
|
+
logger.warn('Refusing to kill own process');
|
|
408
|
+
return {
|
|
409
|
+
action: 'kill_process',
|
|
410
|
+
success: false,
|
|
411
|
+
details: 'Cannot kill own process',
|
|
412
|
+
timestamp: new Date().toISOString(),
|
|
413
|
+
target: String(pid),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
// Step 1: Try SIGTERM (graceful)
|
|
418
|
+
process.kill(pid, 'SIGTERM');
|
|
419
|
+
logger.info(`Sent SIGTERM to PID ${pid}`);
|
|
420
|
+
// Step 2: Verify process is gone, fallback to SIGKILL
|
|
421
|
+
const isAlive = await this.waitForProcessExit(pid, SAFETY_RULES.sigkillTimeoutMs);
|
|
422
|
+
if (isAlive) {
|
|
423
|
+
try {
|
|
424
|
+
process.kill(pid, 'SIGKILL');
|
|
425
|
+
logger.warn(`SIGTERM failed, sent SIGKILL to PID ${pid}`);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Process may have exited between check and kill
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
this.recordAction('kill_process', String(pid), verdict);
|
|
432
|
+
return {
|
|
433
|
+
action: 'kill_process',
|
|
434
|
+
success: true,
|
|
435
|
+
details: `Process PID ${pid} terminated${isAlive ? ' (SIGKILL required)' : ''}`,
|
|
436
|
+
timestamp: new Date().toISOString(),
|
|
437
|
+
target: String(pid),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
442
|
+
logger.error(`Failed to kill process ${pid}: ${msg}`);
|
|
443
|
+
return {
|
|
444
|
+
action: 'kill_process',
|
|
445
|
+
success: false,
|
|
446
|
+
details: `Failed to kill process ${pid}: ${msg}`,
|
|
447
|
+
timestamp: new Date().toISOString(),
|
|
448
|
+
target: String(pid),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Wait for a process to exit, return true if still alive after timeout
|
|
454
|
+
*/
|
|
455
|
+
async waitForProcessExit(pid, timeoutMs) {
|
|
456
|
+
const start = Date.now();
|
|
457
|
+
while (Date.now() - start < timeoutMs) {
|
|
458
|
+
try {
|
|
459
|
+
// Signal 0 checks if process exists without killing it
|
|
460
|
+
process.kill(pid, 0);
|
|
461
|
+
// Process still alive, wait a bit
|
|
462
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// Process is gone
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Still alive after timeout
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Disable a user account
|
|
474
|
+
*/
|
|
475
|
+
async disableAccount(verdict) {
|
|
476
|
+
const username = this.extractUsername(verdict);
|
|
477
|
+
if (!username) {
|
|
478
|
+
return {
|
|
479
|
+
action: 'disable_account',
|
|
480
|
+
success: false,
|
|
481
|
+
details: 'No username found in verdict evidence',
|
|
482
|
+
timestamp: new Date().toISOString(),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (SAFETY_RULES.protectedAccounts.has(username)) {
|
|
486
|
+
logger.warn(`Refusing to disable protected account: ${username}`);
|
|
487
|
+
return {
|
|
488
|
+
action: 'disable_account',
|
|
489
|
+
success: false,
|
|
490
|
+
details: `Account ${username} is protected and cannot be disabled`,
|
|
491
|
+
timestamp: new Date().toISOString(),
|
|
492
|
+
target: username,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
|
496
|
+
return {
|
|
497
|
+
action: 'disable_account',
|
|
498
|
+
success: false,
|
|
499
|
+
details: `Invalid username format: ${username}`,
|
|
500
|
+
timestamp: new Date().toISOString(),
|
|
501
|
+
target: username,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const os = platform();
|
|
505
|
+
try {
|
|
506
|
+
if (os === 'darwin') {
|
|
507
|
+
await execFilePromise('/usr/bin/dscl', [
|
|
508
|
+
'.',
|
|
509
|
+
'-create',
|
|
510
|
+
`/Users/${username}`,
|
|
511
|
+
'AuthenticationAuthority',
|
|
512
|
+
';DisabledUser;',
|
|
513
|
+
]);
|
|
514
|
+
}
|
|
515
|
+
else if (os === 'linux') {
|
|
516
|
+
await execFilePromise('/usr/sbin/usermod', ['-L', username]);
|
|
517
|
+
}
|
|
518
|
+
else if (os === 'win32') {
|
|
519
|
+
await execFilePromise('net', ['user', username, '/active:no']);
|
|
520
|
+
}
|
|
521
|
+
this.recordAction('disable_account', username, verdict);
|
|
522
|
+
logger.info(`Disabled account: ${username}`);
|
|
523
|
+
return {
|
|
524
|
+
action: 'disable_account',
|
|
525
|
+
success: true,
|
|
526
|
+
details: `Account ${username} disabled`,
|
|
527
|
+
timestamp: new Date().toISOString(),
|
|
528
|
+
target: username,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
533
|
+
logger.error(`Failed to disable account ${username}: ${msg}`);
|
|
534
|
+
return {
|
|
535
|
+
action: 'disable_account',
|
|
536
|
+
success: false,
|
|
537
|
+
details: `Failed to disable account: ${msg}`,
|
|
538
|
+
timestamp: new Date().toISOString(),
|
|
539
|
+
target: username,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Isolate a file (move to quarantine) with metadata tracking
|
|
545
|
+
*/
|
|
546
|
+
async isolateFile(verdict) {
|
|
547
|
+
const filePath = this.extractFilePath(verdict);
|
|
548
|
+
if (!filePath) {
|
|
549
|
+
return {
|
|
550
|
+
action: 'isolate_file',
|
|
551
|
+
success: false,
|
|
552
|
+
details: 'No file path found in verdict evidence',
|
|
553
|
+
timestamp: new Date().toISOString(),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
const quarantineDir = '/var/panguard-guard/quarantine';
|
|
558
|
+
const os = platform();
|
|
559
|
+
const mvCmd = os === 'win32' ? 'move' : '/bin/mv';
|
|
560
|
+
const fileName = filePath.split(/[/\\]/).pop() ?? 'unknown';
|
|
561
|
+
const dest = `${quarantineDir}/${Date.now()}_${fileName}`;
|
|
562
|
+
// Ensure quarantine directory exists
|
|
563
|
+
if (os !== 'win32') {
|
|
564
|
+
await execFilePromise('/bin/mkdir', ['-p', quarantineDir]);
|
|
565
|
+
}
|
|
566
|
+
await execFilePromise(mvCmd, [filePath, dest]);
|
|
567
|
+
// Record with metadata for forensics
|
|
568
|
+
this.recordAction('isolate_file', filePath, verdict);
|
|
569
|
+
// Write quarantine metadata alongside the file
|
|
570
|
+
try {
|
|
571
|
+
const metadata = {
|
|
572
|
+
originalPath: filePath,
|
|
573
|
+
quarantinedAt: new Date().toISOString(),
|
|
574
|
+
verdict: { conclusion: verdict.conclusion, confidence: verdict.confidence },
|
|
575
|
+
reasoning: verdict.reasoning,
|
|
576
|
+
};
|
|
577
|
+
appendFileSync(`${dest}.meta.json`, JSON.stringify(metadata, null, 2), 'utf-8');
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// Non-critical: metadata write failure
|
|
581
|
+
}
|
|
582
|
+
logger.info(`Isolated file: ${filePath} -> ${dest}`);
|
|
583
|
+
return {
|
|
584
|
+
action: 'isolate_file',
|
|
585
|
+
success: true,
|
|
586
|
+
details: `File isolated: ${filePath} -> ${dest}`,
|
|
587
|
+
timestamp: new Date().toISOString(),
|
|
588
|
+
target: filePath,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
593
|
+
logger.error(`Failed to isolate file: ${msg}`);
|
|
594
|
+
return {
|
|
595
|
+
action: 'isolate_file',
|
|
596
|
+
success: false,
|
|
597
|
+
details: `Failed to isolate file: ${msg}`,
|
|
598
|
+
timestamp: new Date().toISOString(),
|
|
599
|
+
target: filePath,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Action Manifest (persistence)
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
recordAction(action, target, verdict, expiresAt) {
|
|
607
|
+
const entry = {
|
|
608
|
+
id: `act-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
609
|
+
action,
|
|
610
|
+
target,
|
|
611
|
+
timestamp: new Date().toISOString(),
|
|
612
|
+
expiresAt,
|
|
613
|
+
rolledBack: false,
|
|
614
|
+
verdict: { conclusion: verdict.conclusion, confidence: verdict.confidence },
|
|
615
|
+
};
|
|
616
|
+
this.manifest.push(entry);
|
|
617
|
+
this.persistManifestEntry(entry);
|
|
618
|
+
return entry;
|
|
619
|
+
}
|
|
620
|
+
persistManifestEntry(entry) {
|
|
621
|
+
try {
|
|
622
|
+
appendFileSync(this.manifestPath, JSON.stringify(entry) + '\n', 'utf-8');
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
626
|
+
logger.error(`Failed to persist action manifest: ${msg}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
loadManifest() {
|
|
630
|
+
try {
|
|
631
|
+
const content = readFileSync(this.manifestPath, 'utf-8');
|
|
632
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
633
|
+
for (const line of lines) {
|
|
634
|
+
try {
|
|
635
|
+
const entry = JSON.parse(line);
|
|
636
|
+
this.manifest.push(entry);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// Skip malformed lines
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
logger.info(`Loaded ${this.manifest.length} action manifest entries`);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// Manifest file may not exist yet
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// Escalation tracking
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
trackEscalation(target) {
|
|
652
|
+
const now = new Date().toISOString();
|
|
653
|
+
const existing = this.escalationMap.get(target);
|
|
654
|
+
if (existing) {
|
|
655
|
+
existing.violationCount += 1;
|
|
656
|
+
existing.lastSeen = now;
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
this.escalationMap.set(target, {
|
|
660
|
+
target,
|
|
661
|
+
violationCount: 1,
|
|
662
|
+
firstSeen: now,
|
|
663
|
+
lastSeen: now,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
extractTarget(verdict) {
|
|
668
|
+
return (this.extractIP(verdict) ??
|
|
669
|
+
this.extractProcessName(verdict) ??
|
|
670
|
+
this.extractUsername(verdict) ??
|
|
671
|
+
this.extractFilePath(verdict));
|
|
672
|
+
}
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Evidence extraction helpers
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
extractIP(verdict) {
|
|
677
|
+
for (const e of verdict.evidence) {
|
|
678
|
+
const data = e.data;
|
|
679
|
+
if (data?.['ip'])
|
|
680
|
+
return data['ip'];
|
|
681
|
+
if (data?.['sourceIP'])
|
|
682
|
+
return data['sourceIP'];
|
|
683
|
+
}
|
|
684
|
+
return undefined;
|
|
685
|
+
}
|
|
686
|
+
extractPID(verdict) {
|
|
687
|
+
for (const e of verdict.evidence) {
|
|
688
|
+
const data = e.data;
|
|
689
|
+
if (data?.['pid'])
|
|
690
|
+
return Number(data['pid']);
|
|
691
|
+
}
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
extractUsername(verdict) {
|
|
695
|
+
for (const e of verdict.evidence) {
|
|
696
|
+
const data = e.data;
|
|
697
|
+
if (data?.['username'])
|
|
698
|
+
return data['username'];
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
extractFilePath(verdict) {
|
|
703
|
+
for (const e of verdict.evidence) {
|
|
704
|
+
const data = e.data;
|
|
705
|
+
if (data?.['filePath'])
|
|
706
|
+
return data['filePath'];
|
|
707
|
+
}
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
extractProcessName(verdict) {
|
|
711
|
+
for (const e of verdict.evidence) {
|
|
712
|
+
const data = e.data;
|
|
713
|
+
if (data?.['processName'])
|
|
714
|
+
return data['processName'];
|
|
715
|
+
}
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
/** Get total action count */
|
|
719
|
+
getActionCount() {
|
|
720
|
+
return this.actionCount;
|
|
721
|
+
}
|
|
722
|
+
/** Get escalation records */
|
|
723
|
+
getEscalationRecords() {
|
|
724
|
+
return new Map(this.escalationMap);
|
|
725
|
+
}
|
|
726
|
+
/** Cleanup: clear all timers (for graceful shutdown) */
|
|
727
|
+
destroy() {
|
|
728
|
+
for (const timer of this.unblockTimers.values()) {
|
|
729
|
+
clearTimeout(timer);
|
|
730
|
+
}
|
|
731
|
+
this.unblockTimers.clear();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
// Utility
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
function execFilePromise(command, args) {
|
|
738
|
+
return new Promise((resolve, reject) => {
|
|
739
|
+
execFile(command, args, { timeout: 10000 }, (error, stdout) => {
|
|
740
|
+
if (error) {
|
|
741
|
+
reject(error);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
resolve(stdout);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
//# sourceMappingURL=respond-agent.js.map
|