whitzard-claw 1.0.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.
Files changed (91) hide show
  1. package/README.md +89 -0
  2. package/bin/whitzard-tui.js +73 -0
  3. package/bin/whitzard-webui.js +67 -0
  4. package/dist/tui/tui.js +38733 -0
  5. package/dist/webui/index.html +1235 -0
  6. package/dist/webui/server.js +876 -0
  7. package/ioc/c2-ips.txt +25 -0
  8. package/ioc/file-hashes.txt +13 -0
  9. package/ioc/malicious-domains.txt +46 -0
  10. package/ioc/malicious-hashes.txt +5 -0
  11. package/ioc/malicious-publishers.txt +34 -0
  12. package/ioc/malicious-skill-patterns.txt +87 -0
  13. package/package.json +50 -0
  14. package/scripts/check/access_control.sh +183 -0
  15. package/scripts/check/credential_storage.sh +222 -0
  16. package/scripts/check/execution_sandbox.sh +502 -0
  17. package/scripts/check/memory_poisoning.sh +334 -0
  18. package/scripts/check/network_exposure.sh +479 -0
  19. package/scripts/check/resource_cost.sh +182 -0
  20. package/scripts/check/supply_chain.sh +553 -0
  21. package/scripts/repair/access_control/_common.sh +249 -0
  22. package/scripts/repair/access_control/check_1.sh +28 -0
  23. package/scripts/repair/access_control/check_2.sh +27 -0
  24. package/scripts/repair/access_control/check_3.sh +23 -0
  25. package/scripts/repair/access_control/check_4.sh +23 -0
  26. package/scripts/repair/access_control/check_5.sh +20 -0
  27. package/scripts/repair/credential_storage/_common.sh +277 -0
  28. package/scripts/repair/credential_storage/check_1.sh +47 -0
  29. package/scripts/repair/credential_storage/check_2.sh +35 -0
  30. package/scripts/repair/credential_storage/check_3.sh +53 -0
  31. package/scripts/repair/credential_storage/logs/security-scan.log +15 -0
  32. package/scripts/repair/execution_sandbox/_common.sh +302 -0
  33. package/scripts/repair/execution_sandbox/check_1.sh +67 -0
  34. package/scripts/repair/execution_sandbox/check_10.sh +23 -0
  35. package/scripts/repair/execution_sandbox/check_11.sh +34 -0
  36. package/scripts/repair/execution_sandbox/check_12.sh +38 -0
  37. package/scripts/repair/execution_sandbox/check_13.sh +29 -0
  38. package/scripts/repair/execution_sandbox/check_2.sh +46 -0
  39. package/scripts/repair/execution_sandbox/check_3.sh +37 -0
  40. package/scripts/repair/execution_sandbox/check_4.sh +23 -0
  41. package/scripts/repair/execution_sandbox/check_5.sh +28 -0
  42. package/scripts/repair/execution_sandbox/check_6.sh +17 -0
  43. package/scripts/repair/execution_sandbox/check_7.sh +17 -0
  44. package/scripts/repair/execution_sandbox/check_8.sh +17 -0
  45. package/scripts/repair/execution_sandbox/check_9.sh +17 -0
  46. package/scripts/repair/execution_sandbox/logs/security-scan.log +10 -0
  47. package/scripts/repair/memory_poisoning/_common.sh +336 -0
  48. package/scripts/repair/memory_poisoning/check_1.sh +51 -0
  49. package/scripts/repair/memory_poisoning/check_2.sh +26 -0
  50. package/scripts/repair/memory_poisoning/check_3.sh +24 -0
  51. package/scripts/repair/memory_poisoning/check_4.sh +27 -0
  52. package/scripts/repair/memory_poisoning/check_5.sh +20 -0
  53. package/scripts/repair/network_exposure/_common.sh +330 -0
  54. package/scripts/repair/network_exposure/check_1.sh +86 -0
  55. package/scripts/repair/network_exposure/check_10.sh +16 -0
  56. package/scripts/repair/network_exposure/check_11.sh +31 -0
  57. package/scripts/repair/network_exposure/check_12.sh +24 -0
  58. package/scripts/repair/network_exposure/check_2.sh +26 -0
  59. package/scripts/repair/network_exposure/check_3.sh +43 -0
  60. package/scripts/repair/network_exposure/check_4.sh +23 -0
  61. package/scripts/repair/network_exposure/check_5.sh +16 -0
  62. package/scripts/repair/network_exposure/check_6.sh +98 -0
  63. package/scripts/repair/network_exposure/check_7.sh +35 -0
  64. package/scripts/repair/network_exposure/check_8.sh +19 -0
  65. package/scripts/repair/network_exposure/check_9.sh +19 -0
  66. package/scripts/repair/resource_cost/_common.sh +303 -0
  67. package/scripts/repair/resource_cost/check_1.sh +16 -0
  68. package/scripts/repair/resource_cost/check_2.sh +16 -0
  69. package/scripts/repair/resource_cost/check_3.sh +23 -0
  70. package/scripts/repair/supply_chain/_common.sh +222 -0
  71. package/scripts/repair/supply_chain/check_1.sh +95 -0
  72. package/scripts/repair/supply_chain/check_10.sh +60 -0
  73. package/scripts/repair/supply_chain/check_11.sh +63 -0
  74. package/scripts/repair/supply_chain/check_12.sh +36 -0
  75. package/scripts/repair/supply_chain/check_13.sh +44 -0
  76. package/scripts/repair/supply_chain/check_14.sh +33 -0
  77. package/scripts/repair/supply_chain/check_15.sh +33 -0
  78. package/scripts/repair/supply_chain/check_16.sh +34 -0
  79. package/scripts/repair/supply_chain/check_17.sh +61 -0
  80. package/scripts/repair/supply_chain/check_18.sh +62 -0
  81. package/scripts/repair/supply_chain/check_2.sh +93 -0
  82. package/scripts/repair/supply_chain/check_3.sh +78 -0
  83. package/scripts/repair/supply_chain/check_4.sh +72 -0
  84. package/scripts/repair/supply_chain/check_5.sh +73 -0
  85. package/scripts/repair/supply_chain/check_6.sh +81 -0
  86. package/scripts/repair/supply_chain/check_7.sh +52 -0
  87. package/scripts/repair/supply_chain/check_8.sh +71 -0
  88. package/scripts/repair/supply_chain/check_9.sh +78 -0
  89. package/scripts/repair/supply_chain/logs/security-scan.log +77 -0
  90. package/scripts/scan.sh +228 -0
  91. package/webui/index.html +1235 -0
@@ -0,0 +1,876 @@
1
+ #!/usr/bin/env node
2
+
3
+ import express from 'express';
4
+ import { spawn } from 'child_process';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const HOME_DIR = os.homedir();
11
+
12
+ // Get dynamic paths based on installation location
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ // Determine package directory (works for both development and installed scenarios)
17
+ function getPackageDir() {
18
+ // Check if we're in dist/webui folder (installed package)
19
+ const distWebuiIndex = __dirname.lastIndexOf('dist' + path.sep + 'webui');
20
+ if (distWebuiIndex !== -1) {
21
+ return __dirname.substring(0, distWebuiIndex);
22
+ }
23
+
24
+ // Check if we're in a node_modules package (npm install scenario)
25
+ const nodeModulesIndex = __dirname.lastIndexOf(path.sep + 'node_modules' + path.sep + 'whitzard-claw');
26
+ if (nodeModulesIndex !== -1) {
27
+ return __dirname.substring(0, nodeModulesIndex + path.sep + 'node_modules' + path.sep + 'whitzard-claw');
28
+ }
29
+
30
+ // Development mode: parent is project root
31
+ return path.resolve(__dirname, '..');
32
+ }
33
+
34
+ const PORT = process.env.PORT || 12340;
35
+ const PACKAGE_DIR = getPackageDir();
36
+ const SCRIPTS_DIR = path.join(PACKAGE_DIR, 'scripts');
37
+ const IOC_DIR = path.join(PACKAGE_DIR, 'ioc');
38
+ const LOGS_DIR = path.join(PACKAGE_DIR, 'logs');
39
+ const SCAN_SCRIPT = path.join(SCRIPTS_DIR, 'scan.sh');
40
+ const SETTINGS_FILE = path.join(PACKAGE_DIR, '.openclaw_settings.json');
41
+
42
+ const app = express();
43
+
44
+ // In-memory storage for scan results
45
+ const scanTasks = new Map();
46
+ const scanResults = new Map();
47
+ let currentScanTaskId = null;
48
+
49
+ // Settings storage functions
50
+ function loadSettings() {
51
+ try {
52
+ if (fs.existsSync(SETTINGS_FILE)) {
53
+ const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
54
+ return JSON.parse(data);
55
+ }
56
+ } catch (error) {
57
+ console.error('Error loading settings:', error);
58
+ }
59
+ return {};
60
+ }
61
+
62
+ function saveSettings(settings) {
63
+ try {
64
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
65
+ return true;
66
+ } catch (error) {
67
+ console.error('Error saving settings:', error);
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function getOpenclawHome() {
73
+ const settings = loadSettings();
74
+ // Default to ~/.openclaw in package root
75
+ return settings.openclawHome || path.join(HOME_DIR, '.openclaw');
76
+ }
77
+
78
+ // Middleware
79
+ app.use(express.json());
80
+ app.use(express.static(path.join(PACKAGE_DIR, 'webui')));
81
+
82
+ // Security headers
83
+ app.use((req, res, next) => {
84
+ res.setHeader('X-Content-Type-Options', 'nosniff');
85
+ res.setHeader('X-Frame-Options', 'DENY');
86
+ res.setHeader('X-XSS-Protection', '1; mode=block');
87
+ next();
88
+ });
89
+
90
+ // Generate unique task ID
91
+ function generateTaskId() {
92
+ return `scan_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
93
+ }
94
+
95
+ // Parse scan output line by line
96
+ function parseScanLine(line, currentCategory) {
97
+ const result = { type: 'unknown', data: {} };
98
+
99
+ // Check header: [1/12] Auditing gateway...
100
+ const headerMatch = line.match(/^\[(\d+)\/(\d+)\]\s+(.+)/);
101
+ if (headerMatch) {
102
+ return {
103
+ type: 'check-start',
104
+ data: {
105
+ num: parseInt(headerMatch[1]),
106
+ total: parseInt(headerMatch[2]),
107
+ name: headerMatch[3].replace(/\.\.\.$/, '').trim(),
108
+ category: currentCategory
109
+ }
110
+ };
111
+ }
112
+
113
+ // Check result lines
114
+ if (line.startsWith('CLEAN:')) {
115
+ return { type: 'check-result', data: { status: 'CLEAN', details: line.substring(6).trim() } };
116
+ }
117
+ if (line.startsWith('WARNING:')) {
118
+ return { type: 'check-result', data: { status: 'WARNING', details: line.substring(8).trim() } };
119
+ }
120
+ if (line.startsWith('CRITICAL:')) {
121
+ return { type: 'check-result', data: { status: 'CRITICAL', details: line.substring(9).trim() } };
122
+ }
123
+
124
+ // Check category start
125
+ const categoryStartMatch = line.match(/^CATEGORY START:\s*(\w+)\s*\((\d+)\s+checks?\)/i);
126
+ if (categoryStartMatch) {
127
+ return {
128
+ type: 'category-start',
129
+ data: {
130
+ name: categoryStartMatch[1],
131
+ totalChecks: parseInt(categoryStartMatch[2])
132
+ }
133
+ };
134
+ }
135
+
136
+ // Check category end
137
+ const categoryEndMatch = line.match(/^CATEGORY SUMMARY:\s*(\w+)/i);
138
+ if (categoryEndMatch) {
139
+ return { type: 'category-end', data: { name: categoryEndMatch[1] } };
140
+ }
141
+
142
+ // Check final summary
143
+ const summaryMatch = line.match(/SCAN COMPLETE:\s*(\d+)\s*critical,\s*(\d+)\s*warnings?,\s*(\d+)\s*clean/i);
144
+ if (summaryMatch) {
145
+ return {
146
+ type: 'scan-summary',
147
+ data: {
148
+ critical: parseInt(summaryMatch[1]),
149
+ warnings: parseInt(summaryMatch[2]),
150
+ clean: parseInt(summaryMatch[3])
151
+ }
152
+ };
153
+ }
154
+
155
+ // Check status
156
+ const statusMatch = line.match(/STATUS:\s*(\w+)/i);
157
+ if (statusMatch) {
158
+ return { type: 'scan-status', data: { status: statusMatch[1] } };
159
+ }
160
+
161
+ return result;
162
+ }
163
+
164
+ // Run scan script
165
+ function runScan(taskId) {
166
+ return new Promise((resolve) => {
167
+ const openclawHome = getOpenclawHome();
168
+ const env = {
169
+ ...process.env,
170
+ PATH: `${process.env.HOME}/.local/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}`,
171
+ HOME: process.env.HOME,
172
+ OPENCLAW_HOME: openclawHome
173
+ };
174
+
175
+ console.log(`[SCAN] Using OPENCLAW_HOME: ${openclawHome}`);
176
+
177
+ const child = spawn('/bin/bash', [SCAN_SCRIPT], {
178
+ env,
179
+ cwd: PACKAGE_DIR,
180
+ shell: false
181
+ });
182
+
183
+ let outputBuffer = '';
184
+ let currentCategory = null;
185
+ let currentCheck = null;
186
+ const categories = new Map();
187
+ const checks = [];
188
+
189
+ const task = scanTasks.get(taskId);
190
+ if (!task) {
191
+ resolve({ success: false, error: 'Task not found' });
192
+ return;
193
+ }
194
+
195
+ task.process = child;
196
+ task.startTime = Date.now();
197
+
198
+ child.stdout.on('data', (data) => {
199
+ const lines = data.toString().split('\n');
200
+
201
+ for (const line of lines) {
202
+ if (!line.trim()) continue;
203
+
204
+ outputBuffer += line + '\n';
205
+ const parsed = parseScanLine(line, currentCategory);
206
+
207
+ switch (parsed.type) {
208
+ case 'category-start':
209
+ currentCategory = parsed.data.name;
210
+ categories.set(currentCategory, {
211
+ name: parsed.data.name,
212
+ totalChecks: parsed.data.totalChecks,
213
+ completedChecks: 0,
214
+ critical: 0,
215
+ warnings: 0,
216
+ clean: 0,
217
+ checks: []
218
+ });
219
+ task.completedChecks = 0;
220
+ task.currentCategory = currentCategory;
221
+ broadcastSSE(taskId, 'category-start', parsed.data);
222
+ break;
223
+
224
+ case 'check-start':
225
+ currentCheck = {
226
+ num: parsed.data.num,
227
+ name: parsed.data.name,
228
+ category: currentCategory,
229
+ status: 'RUNNING',
230
+ details: ''
231
+ };
232
+ break;
233
+
234
+ case 'check-result':
235
+ if (currentCheck) {
236
+ // If this is the first result for this check, set status and details
237
+ if (!currentCheck.status || currentCheck.status === 'RUNNING') {
238
+ currentCheck.status = parsed.data.status;
239
+ currentCheck.details = parsed.data.details;
240
+ currentCheck.allDetails = [parsed.data.details]; // Start collecting all details
241
+ } else {
242
+ // Same check has multiple results - merge them
243
+ // Keep the most severe status (CRITICAL > WARNING > CLEAN)
244
+ const severityOrder = { 'CRITICAL': 3, 'WARNING': 2, 'CLEAN': 1 };
245
+ if (severityOrder[parsed.data.status] > severityOrder[currentCheck.status]) {
246
+ currentCheck.status = parsed.data.status;
247
+ }
248
+
249
+ // Add unique details only
250
+ if (!currentCheck.allDetails.includes(parsed.data.details)) {
251
+ currentCheck.allDetails.push(parsed.data.details);
252
+ }
253
+ // Update details to show all (joined)
254
+ currentCheck.details = currentCheck.allDetails.join('\n');
255
+ }
256
+
257
+ const category = categories.get(currentCategory);
258
+ if (category) {
259
+ // Only add to checks array once (on first result)
260
+ if (!category.checks.find(c => c.num === currentCheck.num)) {
261
+ category.checks.push({ ...currentCheck });
262
+ } else {
263
+ // Update existing check with merged details
264
+ const existingCheck = category.checks.find(c => c.num === currentCheck.num);
265
+ existingCheck.status = currentCheck.status;
266
+ existingCheck.details = currentCheck.details;
267
+ existingCheck.allDetails = currentCheck.allDetails;
268
+ }
269
+
270
+ category.completedChecks++;
271
+
272
+ // Update category counters based on final status
273
+ if (currentCheck.status === 'CLEAN') {
274
+ category.clean++;
275
+ } else if (currentCheck.status === 'WARNING') {
276
+ category.warnings++;
277
+ } else if (currentCheck.status === 'CRITICAL') {
278
+ category.critical++;
279
+ }
280
+
281
+ task.completedChecks++;
282
+ }
283
+
284
+ // Broadcast update
285
+ broadcastSSE(taskId, 'check-result', {
286
+ ...currentCheck,
287
+ categoryStats: categories.get(currentCategory)
288
+ });
289
+ currentCheck = null;
290
+ }
291
+ break;
292
+
293
+ case 'category-end':
294
+ broadcastSSE(taskId, 'category-end', categories.get(parsed.data.name));
295
+ break;
296
+
297
+ case 'scan-summary':
298
+ task.summary = parsed.data;
299
+ break;
300
+
301
+ case 'scan-status':
302
+ task.status = parsed.data.status;
303
+ break;
304
+ }
305
+ }
306
+ });
307
+
308
+ child.stderr.on('data', (data) => {
309
+ console.error(`[${taskId}] stderr:`, data.toString());
310
+ });
311
+
312
+ child.on('close', (code) => {
313
+ task.endTime = Date.now();
314
+ task.exitCode = code;
315
+
316
+ const result = {
317
+ taskId,
318
+ timestamp: new Date(task.startTime).toISOString(),
319
+ duration: task.endTime - task.startTime,
320
+ exitCode: code,
321
+ summary: task.summary || { critical: 0, warnings: 0, clean: 0 },
322
+ status: task.status || (code === 0 ? 'SECURE' : code === 1 ? 'WARNING' : 'COMPROMISED'),
323
+ categories: Array.from(categories.values()),
324
+ checks: checks,
325
+ rawOutput: outputBuffer
326
+ };
327
+
328
+ scanResults.set(taskId, result);
329
+ scanTasks.delete(taskId);
330
+ currentScanTaskId = null;
331
+
332
+ broadcastSSE(taskId, 'scan-complete', result);
333
+ resolve({ success: true, result });
334
+ });
335
+
336
+ child.on('error', (err) => {
337
+ task.endTime = Date.now();
338
+ task.status = 'FAILED';
339
+ task.error = err.message;
340
+
341
+ scanTasks.delete(taskId);
342
+ currentScanTaskId = null;
343
+
344
+ broadcastSSE(taskId, 'scan-error', { error: err.message });
345
+ resolve({ success: false, error: err.message });
346
+ });
347
+ });
348
+ }
349
+
350
+ // SSE clients
351
+ const sseClients = new Map();
352
+
353
+ function broadcastSSE(taskId, event, data) {
354
+ const clients = sseClients.get(taskId) || [];
355
+ const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
356
+
357
+ clients.forEach(client => {
358
+ try {
359
+ client.res.write(message);
360
+ } catch (e) {
361
+ // Client disconnected, will be cleaned up
362
+ }
363
+ });
364
+ }
365
+
366
+ // Routes
367
+
368
+ // Serve index.html
369
+ app.get('/', (req, res) => {
370
+ res.sendFile(path.join(__dirname, 'index.html'));
371
+ });
372
+
373
+ // Start scan
374
+ app.post('/api/scan/run', async (req, res) => {
375
+ if (currentScanTaskId) {
376
+ return res.status(409).json({
377
+ error: 'Scan already in progress',
378
+ taskId: currentScanTaskId
379
+ });
380
+ }
381
+
382
+ const taskId = generateTaskId();
383
+ const task = {
384
+ id: taskId,
385
+ status: 'STARTING',
386
+ startTime: null,
387
+ endTime: null,
388
+ currentCategory: null,
389
+ completedChecks: 0,
390
+ totalChecks: 59, // 12+5+13+3+5+18+3
391
+ summary: null,
392
+ process: null
393
+ };
394
+
395
+ scanTasks.set(taskId, task);
396
+ currentScanTaskId = taskId;
397
+ sseClients.set(taskId, []);
398
+
399
+ res.json({ taskId, status: 'started' });
400
+
401
+ // Run scan asynchronously
402
+ runScan(taskId);
403
+ });
404
+
405
+ // SSE endpoint
406
+ app.get('/api/scan/sse', (req, res) => {
407
+ const { taskId } = req.query;
408
+
409
+ if (!taskId || !scanTasks.has(taskId)) {
410
+ // Allow connecting to completed scans for result retrieval
411
+ if (!taskId || !scanResults.has(taskId)) {
412
+ return res.status(404).send('Task not found');
413
+ }
414
+ }
415
+
416
+ res.setHeader('Content-Type', 'text/event-stream');
417
+ res.setHeader('Cache-Control', 'no-cache');
418
+ res.setHeader('Connection', 'keep-alive');
419
+ res.setHeader('X-Accel-Buffering', 'no');
420
+
421
+ // Send initial connection confirmation
422
+ res.write(`event: connected\ndata: {"taskId":"${taskId || 'latest'}"}\n\n`);
423
+
424
+ const client = { res, connectedAt: Date.now() };
425
+
426
+ if (taskId) {
427
+ if (!sseClients.has(taskId)) {
428
+ sseClients.set(taskId, []);
429
+ }
430
+ sseClients.get(taskId).push(client);
431
+ }
432
+
433
+ // If task is completed, send the result immediately
434
+ if (taskId && scanResults.has(taskId)) {
435
+ const result = scanResults.get(taskId);
436
+ res.write(`event: scan-complete\ndata: ${JSON.stringify(result)}\n\n`);
437
+ }
438
+
439
+ // Cleanup on disconnect
440
+ req.on('close', () => {
441
+ if (taskId) {
442
+ const clients = sseClients.get(taskId);
443
+ if (clients) {
444
+ const index = clients.indexOf(client);
445
+ if (index > -1) clients.splice(index, 1);
446
+ if (clients.length === 0) {
447
+ sseClients.delete(taskId);
448
+ }
449
+ }
450
+ }
451
+ });
452
+ });
453
+
454
+ // Get scan result
455
+ app.get('/api/scan/:taskId/result', (req, res) => {
456
+ const { taskId } = req.params;
457
+
458
+ if (scanResults.has(taskId)) {
459
+ res.json(scanResults.get(taskId));
460
+ } else if (scanTasks.has(taskId)) {
461
+ const task = scanTasks.get(taskId);
462
+ res.json({
463
+ taskId,
464
+ status: task.status,
465
+ currentCategory: task.currentCategory,
466
+ completedChecks: task.completedChecks,
467
+ totalChecks: task.totalChecks,
468
+ progress: Math.round((task.completedChecks / task.totalChecks) * 100)
469
+ });
470
+ } else {
471
+ res.status(404).json({ error: 'Task not found' });
472
+ }
473
+ });
474
+
475
+ // Get latest scan result
476
+ app.get('/api/scan/latest', (req, res) => {
477
+ const latestTaskId = Array.from(scanResults.keys()).pop();
478
+
479
+ if (latestTaskId) {
480
+ res.json(scanResults.get(latestTaskId));
481
+ } else {
482
+ res.json({ error: 'No scans run yet' });
483
+ }
484
+ });
485
+
486
+ // Get scan history (last 10 results)
487
+ app.get('/api/scan/history', (req, res) => {
488
+ const history = Array.from(scanResults.entries())
489
+ .map(([id, result]) => ({
490
+ taskId: id,
491
+ timestamp: result.timestamp,
492
+ status: result.status,
493
+ summary: result.summary,
494
+ duration: result.duration
495
+ }))
496
+ .slice(-10)
497
+ .reverse();
498
+
499
+ res.json(history);
500
+ });
501
+
502
+ // Cancel running scan
503
+ app.post('/api/scan/:taskId/cancel', (req, res) => {
504
+ const { taskId } = req.params;
505
+
506
+ if (!scanTasks.has(taskId)) {
507
+ return res.status(404).json({ error: 'Task not found or already completed' });
508
+ }
509
+
510
+ const task = scanTasks.get(taskId);
511
+ if (task.process) {
512
+ task.process.kill('SIGTERM');
513
+ res.json({ taskId, status: 'cancelled' });
514
+ } else {
515
+ res.status(500).json({ error: 'Cannot cancel scan' });
516
+ }
517
+ });
518
+
519
+ // Health check
520
+ app.get('/api/health', (req, res) => {
521
+ res.json({
522
+ status: 'ok',
523
+ uptime: process.uptime(),
524
+ activeScans: scanTasks.size,
525
+ completedScans: scanResults.size
526
+ });
527
+ });
528
+
529
+ // Get scan logs (real-time)
530
+ app.get('/api/scan/logs', (req, res) => {
531
+ const logFilePath = path.join(LOGS_DIR, 'security-scan.log');
532
+
533
+ if (!fs.existsSync(logFilePath)) {
534
+ return res.json({ logs: [], exists: false });
535
+ }
536
+
537
+ try {
538
+ const content = fs.readFileSync(logFilePath, 'utf8');
539
+ const lines = content.split('\n').filter(line => line.trim() !== '');
540
+ res.json({
541
+ logs: lines,
542
+ exists: true,
543
+ lineCount: lines.length
544
+ });
545
+ } catch (error) {
546
+ res.status(500).json({ error: error.message });
547
+ }
548
+ });
549
+
550
+ // SSE endpoint for real-time log streaming
551
+ app.get('/api/scan/logs/sse', (req, res) => {
552
+ const { taskId } = req.query;
553
+ const logFilePath = path.join(LOGS_DIR, 'security-scan.log');
554
+
555
+ res.setHeader('Content-Type', 'text/event-stream');
556
+ res.setHeader('Cache-Control', 'no-cache');
557
+ res.setHeader('Connection', 'keep-alive');
558
+ res.setHeader('X-Accel-Buffering', 'no');
559
+
560
+ // Send initial connection confirmation
561
+ res.write(`event: connected\ndata: {"status":"connected"}\n\n`);
562
+
563
+ // Send existing logs if file exists
564
+ if (fs.existsSync(logFilePath)) {
565
+ try {
566
+ const content = fs.readFileSync(logFilePath, 'utf8');
567
+ const lines = content.split('\n').filter(line => line.trim() !== '');
568
+ res.write(`event: logs\ndata: ${JSON.stringify({ logs: lines })}\n\n`);
569
+ } catch (e) {
570
+ console.error('Error reading log file:', e);
571
+ }
572
+ }
573
+
574
+ // Watch file for changes if there's an active scan
575
+ let fsWatcher = null;
576
+ let lastSize = 0;
577
+
578
+ if (fs.existsSync(logFilePath)) {
579
+ const stats = fs.statSync(logFilePath);
580
+ lastSize = stats.size;
581
+ }
582
+
583
+ const pollInterval = setInterval(() => {
584
+ if (!fs.existsSync(logFilePath)) {
585
+ return;
586
+ }
587
+
588
+ try {
589
+ const stats = fs.statSync(logFilePath);
590
+
591
+ if (stats.size > lastSize) {
592
+ // File has grown, read new content
593
+ const buffer = Buffer.alloc(stats.size - lastSize);
594
+ const fd = fs.openSync(logFilePath, 'r');
595
+ fs.readSync(fd, buffer, 0, buffer.length, lastSize);
596
+ fs.closeSync(fd);
597
+
598
+ const newContent = buffer.toString('utf8');
599
+ const newLines = newContent.split('\n').filter(line => line.trim() !== '');
600
+
601
+ if (newLines.length > 0) {
602
+ res.write(`event: new-logs\ndata: ${JSON.stringify({ logs: newLines })}\n\n`);
603
+ }
604
+
605
+ lastSize = stats.size;
606
+ }
607
+ } catch (e) {
608
+ console.error('Error polling log file:', e);
609
+ }
610
+ }, 500); // Poll every 500ms
611
+
612
+ // Cleanup on disconnect
613
+ req.on('close', () => {
614
+ clearInterval(pollInterval);
615
+ if (fsWatcher) {
616
+ fsWatcher.close();
617
+ }
618
+ });
619
+ });
620
+
621
+ // Clear logs endpoint (called before starting a new scan)
622
+ app.post('/api/scan/logs/clear', (req, res) => {
623
+ const logFilePath = path.join(LOGS_DIR, 'security-scan.log');
624
+
625
+ try {
626
+ // Ensure logs directory exists
627
+ if (!fs.existsSync(LOGS_DIR)) {
628
+ fs.mkdirSync(LOGS_DIR, { recursive: true });
629
+ }
630
+
631
+ // Clear or create the log file
632
+ fs.writeFileSync(logFilePath, '');
633
+ res.json({ success: true, message: 'Logs cleared' });
634
+ } catch (error) {
635
+ res.status(500).json({ error: error.message });
636
+ }
637
+ });
638
+
639
+ // ============ SETTINGS APIs ============
640
+
641
+ // Get OPENCLAW_HOME setting
642
+ app.get('/api/settings/openclaw_home', (req, res) => {
643
+ const value = getOpenclawHome();
644
+ res.json({ value });
645
+ });
646
+
647
+ // Save OPENCLAW_HOME setting
648
+ app.post('/api/settings/openclaw_home', (req, res) => {
649
+ const { value } = req.body;
650
+
651
+ if (typeof value !== 'string') {
652
+ return res.status(400).json({ error: 'Value must be a string' });
653
+ }
654
+
655
+ const settings = loadSettings();
656
+ settings.openclawHome = value;
657
+
658
+ if (saveSettings(settings)) {
659
+ res.json({ success: true, value });
660
+ } else {
661
+ res.status(500).json({ error: 'Failed to save settings' });
662
+ }
663
+ });
664
+
665
+ // ============ REMEDIATION APIs ============
666
+
667
+ // Find repair script path
668
+ function findRepairScript(category, checkNum) {
669
+ const categoryDir = path.join(SCRIPTS_DIR, 'repair', category.toLowerCase());
670
+ const scriptPath = path.join(categoryDir, `check_${checkNum}.sh`);
671
+
672
+ if (fs.existsSync(scriptPath)) {
673
+ return scriptPath;
674
+ }
675
+ return null;
676
+ }
677
+
678
+ // Parse repair script output
679
+ function parseRepairOutput(output) {
680
+ const result = {
681
+ guidance: '',
682
+ autoFixAvailable: false,
683
+ skills: []
684
+ };
685
+
686
+ // Extract guidance (everything before auto-fix marker)
687
+ const autoFixIndex = output.indexOf('auto-fix\n');
688
+ if (autoFixIndex !== -1) {
689
+ result.guidance = output.substring(0, autoFixIndex).trim();
690
+ result.autoFixAvailable = true;
691
+
692
+ // Extract skill names (lines after auto-fix marker)
693
+ const skillsSection = output.substring(autoFixIndex + 9).trim(); // 'auto-fix\n' is 9 characters
694
+ if (skillsSection.length > 0) {
695
+ result.skills = skillsSection.split('\n')
696
+ .map(s => s.trim())
697
+ .filter(s => s.length > 0 && !s.startsWith('auto-fix')); // Filter out empty lines and duplicate markers
698
+ }
699
+ } else {
700
+ result.guidance = output.trim();
701
+ }
702
+
703
+ return result;
704
+ }
705
+
706
+ // Get remediation guidance for a specific check
707
+ app.post('/api/remediate/guidance', async (req, res) => {
708
+ const { category, checkNum } = req.body;
709
+
710
+ if (!category || !checkNum) {
711
+ return res.status(400).json({ error: 'Missing category or checkNum' });
712
+ }
713
+
714
+ const scriptPath = findRepairScript(category, checkNum);
715
+ if (!scriptPath) {
716
+ return res.status(404).json({ error: 'Repair script not found' });
717
+ }
718
+
719
+ try {
720
+ const openclawHome = getOpenclawHome();
721
+ const env = {
722
+ ...process.env,
723
+ PATH: `${process.env.HOME}/.local/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}`,
724
+ HOME: process.env.HOME,
725
+ OPENCLAW_HOME: openclawHome
726
+ };
727
+
728
+ console.log(`[REMEDIATE] Using OPENCLAW_HOME: ${openclawHome}`);
729
+
730
+ const scriptResult = await new Promise((resolve, reject) => {
731
+ let stdout = '';
732
+ let stderr = '';
733
+
734
+ const child = spawn('/bin/bash', [scriptPath], {
735
+ env,
736
+ cwd: PACKAGE_DIR,
737
+ shell: false
738
+ });
739
+
740
+ child.stdout.on('data', (data) => {
741
+ stdout += data.toString();
742
+ });
743
+
744
+ child.stderr.on('data', (data) => {
745
+ stderr += data.toString();
746
+ });
747
+
748
+ child.on('close', (code) => {
749
+ resolve({ exitCode: code, stdout, stderr });
750
+ });
751
+
752
+ child.on('error', reject);
753
+ });
754
+
755
+ const parsed = parseRepairOutput(scriptResult.stdout);
756
+
757
+ // Determine fix type:
758
+ // - hasAutoFix: true if auto-fix available AND has skills to select (user needs to choose)
759
+ // - directFix: true if auto-fix available but NO skills (can fix directly)
760
+ res.json({
761
+ category,
762
+ checkNum,
763
+ ...parsed,
764
+ hasAutoFix: parsed.autoFixAvailable && parsed.skills.length > 0,
765
+ directFix: parsed.autoFixAvailable && parsed.skills.length === 0
766
+ });
767
+ } catch (error) {
768
+ res.status(500).json({ error: error.message });
769
+ }
770
+ });
771
+
772
+ // Execute auto-fix for a specific skill
773
+ app.post('/api/remediate/execute', async (req, res) => {
774
+ const { category, checkNum, skillName } = req.body;
775
+
776
+ if (!category || !checkNum) {
777
+ return res.status(400).json({ error: 'Missing required parameters' });
778
+ }
779
+
780
+ const scriptPath = findRepairScript(category, checkNum);
781
+ if (!scriptPath) {
782
+ return res.status(404).json({ error: 'Repair script not found' });
783
+ }
784
+
785
+ try {
786
+ const openclawHome = getOpenclawHome();
787
+ console.log(`[AUTO-FIX] Executing repair script: ${scriptPath}`);
788
+ console.log(`[AUTO-FIX] Parameters: category=${category}, checkNum=${checkNum}, skillName=${skillName}`);
789
+ console.log(`[AUTO-FIX] Using OPENCLAW_HOME: ${openclawHome}`);
790
+
791
+ const env = {
792
+ ...process.env,
793
+ PATH: `${process.env.HOME}/.local/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}`,
794
+ HOME: process.env.HOME,
795
+ OPENCLAW_HOME: openclawHome,
796
+ AUTO_FIX: '1'
797
+ };
798
+
799
+ // Set SKILL_NAME only if provided (not null/undefined/empty)
800
+ // skillName=null means direct fix without specific skill name
801
+ if (skillName && skillName !== 'auto' && skillName !== 'null') {
802
+ env.SKILL_NAME = skillName;
803
+ console.log(`[AUTO-FIX] Setting SKILL_NAME=${skillName}`);
804
+ } else {
805
+ console.log(`[AUTO-FIX] No SKILL_NAME set (direct fix mode)`);
806
+ }
807
+
808
+ const result = await new Promise((resolve, reject) => {
809
+ let stdout = '';
810
+ let stderr = '';
811
+
812
+ const child = spawn('/bin/bash', [scriptPath], {
813
+ env,
814
+ cwd: PACKAGE_DIR,
815
+ shell: false
816
+ });
817
+
818
+ child.stdout.on('data', (data) => {
819
+ const text = data.toString();
820
+ stdout += text;
821
+ console.log(`[AUTO-FIX STDOUT] ${text.trim()}`);
822
+ });
823
+
824
+ child.stderr.on('data', (data) => {
825
+ const text = data.toString();
826
+ stderr += text;
827
+ console.error(`[AUTO-FIX STDERR] ${text.trim()}`);
828
+ });
829
+
830
+ child.on('close', (code) => {
831
+ console.log(`[AUTO-FIX] Script closed with exit code: ${code}`);
832
+ resolve({ exitCode: code, stdout, stderr });
833
+ });
834
+
835
+ child.on('error', (err) => {
836
+ console.error(`[AUTO-FIX ERROR] Spawn error: ${err.message}`);
837
+ reject(err);
838
+ });
839
+ });
840
+
841
+ // Check for errors in stderr or non-zero exit code
842
+ if (result.exitCode !== 0) {
843
+ console.error(`[AUTO-FIX] Failed with exit code ${result.exitCode}`);
844
+ console.error(`[AUTO-FIX] stderr: ${result.stderr}`);
845
+ return res.status(500).json({
846
+ success: false,
847
+ error: `Script exited with code ${result.exitCode}: ${result.stderr}`
848
+ });
849
+ }
850
+
851
+ console.log(`[AUTO-FIX] Success! stdout: ${result.stdout}`);
852
+
853
+ res.json({
854
+ success: true,
855
+ skillName: skillName || 'direct-fix',
856
+ output: result.stdout,
857
+ stderr: result.stderr,
858
+ exitCode: result.exitCode,
859
+ message: skillName
860
+ ? `Skill '${skillName}' has been removed successfully`
861
+ : 'Fix applied successfully'
862
+ });
863
+ } catch (error) {
864
+ console.error(`[AUTO-FIX ERROR] ${error.message}`);
865
+ res.status(500).json({
866
+ success: false,
867
+ error: error.message
868
+ });
869
+ }
870
+ });
871
+
872
+ // Start server
873
+ app.listen(PORT, '0.0.0.0', () => {
874
+ console.log(`🛡️ Whitzard-Claw Web UI running at http://localhost:${PORT}`);
875
+ console.log(` Press Ctrl+C to stop`);
876
+ });