@contextfort-ai/openclaw-secure 0.1.8 → 0.1.11

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.
@@ -48,7 +48,7 @@
48
48
  .card-sub { font-size: 12px; color: #a1a1aa; margin-top: 4px; }
49
49
 
50
50
  /* Guard summary cards on home */
51
- .guard-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
51
+ .guard-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
52
52
  .guard-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 16px; background: rgba(255,255,255,0.02); cursor: pointer; transition: all 0.15s; }
53
53
  .guard-card:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.2); }
54
54
  .guard-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
@@ -59,6 +59,7 @@
59
59
  .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
60
60
  .dot-green { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.4); }
61
61
  .dot-red { background: #ef4444; box-shadow: 0 0 6px rgba(239,68,68,0.4); }
62
+ .dot-yellow { background: #eab308; box-shadow: 0 0 6px rgba(234,179,8,0.4); }
62
63
  .dot-gray { background: #52525b; }
63
64
 
64
65
  /* Page header */
@@ -74,7 +75,7 @@
74
75
  th { text-align: left; padding: 10px 16px; color: #a1a1aa; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.02); }
75
76
  td { padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; }
76
77
  tr:hover td { background: rgba(255,255,255,0.02); }
77
- .cmd { font-family: ui-monospace, monospace; font-size: 12px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
78
+ .cmd { font-family: ui-monospace, monospace; font-size: 12px; max-width: 400px; white-space: pre-wrap; word-break: break-all; }
78
79
 
79
80
  /* Badges */
80
81
  .badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 500; }
@@ -107,7 +108,8 @@
107
108
 
108
109
  /* Responsive */
109
110
  @media (max-width: 900px) {
110
- .cards, .guard-cards { grid-template-columns: repeat(2, 1fr); }
111
+ .cards { grid-template-columns: repeat(2, 1fr); }
112
+ .guard-cards { grid-template-columns: repeat(3, 1fr); }
111
113
  .sidebar { width: 170px; }
112
114
  }
113
115
  </style>
@@ -146,6 +148,10 @@
146
148
  <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
147
149
  Home
148
150
  </button>
151
+ <button class="sidebar-btn" data-page="scan" onclick="switchPage('scan')">
152
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
153
+ Secret Scanner
154
+ </button>
149
155
 
150
156
  <div class="sidebar-label">Guards</div>
151
157
 
@@ -169,6 +175,11 @@
169
175
  Secrets Guard
170
176
  <span class="dot dot-green" id="sb-sec-dot" style="margin-left:auto"></span>
171
177
  </button>
178
+ <button class="sidebar-btn" data-page="exfil" onclick="switchPage('exfil')">
179
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
180
+ Secrets Exfil Monitor
181
+ <span class="dot dot-green" id="sb-exfil-dot" style="margin-left:auto"></span>
182
+ </button>
172
183
  </div>
173
184
  </aside>
174
185
 
@@ -189,6 +200,37 @@
189
200
  <div class="card"><div class="card-label">Active Since</div><div class="card-value" id="ov-since" style="font-size:16px">-</div><div class="card-sub">first hook loaded</div></div>
190
201
  </div>
191
202
 
203
+ <!-- Active Block Banner (shown when agent is blocked) -->
204
+ <div id="home-block-banner" style="display:none;margin-bottom:24px;border:1px solid rgba(239,68,68,0.3);border-radius:8px;padding:16px 20px;background:rgba(239,68,68,0.08)">
205
+ <div style="display:flex;align-items:flex-start;gap:12px">
206
+ <svg style="width:20px;height:20px;flex-shrink:0;margin-top:2px" fill="none" stroke="#f87171" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
207
+ <div style="flex:1">
208
+ <div style="font-size:15px;font-weight:600;color:#f87171;margin-bottom:4px" id="home-block-title">Agent Blocked</div>
209
+ <div style="font-size:13px;color:#a1a1aa;margin-bottom:12px" id="home-block-reason"></div>
210
+ <div style="display:flex;gap:8px">
211
+ <button class="btn btn-sm" id="home-block-view-btn" style="color:#f87171;border-color:rgba(239,68,68,0.3)" onclick="">View Details</button>
212
+ <button class="btn btn-sm" id="home-block-remove-btn" style="color:#4ade80;border-color:rgba(34,197,94,0.3)" onclick="removeBlock()">Remove Block &amp; Allow OpenClaw</button>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <!-- Anthropic API Key box (shown when not set) -->
219
+ <div id="anthropic-key-box" style="display:none;margin-bottom:24px;border:1px solid rgba(250,204,21,0.3);border-radius:8px;padding:16px 20px;background:rgba(250,204,21,0.06)">
220
+ <div style="display:flex;align-items:flex-start;gap:12px">
221
+ <svg style="width:20px;height:20px;flex-shrink:0;margin-top:2px" fill="none" stroke="#facc15" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
222
+ <div style="flex:1">
223
+ <div style="font-size:15px;font-weight:600;color:#facc15;margin-bottom:4px">Prompt Injection Detection Inactive</div>
224
+ <div style="font-size:13px;color:#a1a1aa;margin-bottom:12px">An Anthropic API key is required for Haiku to scan command output for prompt injection. Enter your key below or set <code style="background:rgba(255,255,255,0.06);padding:2px 6px;border-radius:4px;font-size:12px">ANTHROPIC_API_KEY</code> in your shell.</div>
225
+ <div style="display:flex;gap:8px;align-items:center">
226
+ <input type="password" id="anthropic-key-input" placeholder="sk-ant-..." style="flex:1;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:8px 12px;color:#e4e4e7;font-size:13px;font-family:ui-monospace,monospace;outline:none" />
227
+ <button class="btn btn-sm" id="anthropic-key-save-btn" style="color:#facc15;border-color:rgba(250,204,21,0.3);white-space:nowrap" onclick="saveAnthropicKey()">Save Key</button>
228
+ </div>
229
+ <div id="anthropic-key-msg" style="font-size:12px;margin-top:8px;display:none"></div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
192
234
  <div class="guard-cards">
193
235
  <div class="guard-card" onclick="switchPage('skill')">
194
236
  <div class="guard-card-header">
@@ -222,6 +264,84 @@
222
264
  </div>
223
265
  <div class="guard-stat"><b id="g-sec-blocks">0</b> blocks &middot; <b id="g-sec-redactions">0</b> redactions</div>
224
266
  </div>
267
+ <div class="guard-card" onclick="switchPage('exfil')">
268
+ <div class="guard-card-header">
269
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
270
+ <span>Secrets Exfil Monitor</span>
271
+ <span class="dot dot-green" id="g-exfil-dot" style="margin-left:auto"></span>
272
+ </div>
273
+ <div class="guard-stat"><b id="g-exfil-detections">0</b> detections</div>
274
+ </div>
275
+ </div>
276
+
277
+ <!-- Secret Scanner Summary -->
278
+ <div style="margin-top:24px">
279
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
280
+ <h2 style="font-size:16px;font-weight:600">Secret Scanner</h2>
281
+ <button class="btn btn-sm" onclick="switchPage('scan')">View Details</button>
282
+ </div>
283
+ <div class="card" id="home-scan-card">
284
+ <div style="display:flex;align-items:center;gap:12px">
285
+ <svg style="width:20px;height:20px;flex-shrink:0" fill="none" stroke="#a1a1aa" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
286
+ <div>
287
+ <div style="font-size:14px" id="home-scan-status">Loading...</div>
288
+ <div style="font-size:12px;color:#a1a1aa;margin-top:2px" id="home-scan-detail"></div>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </div>
294
+
295
+ <!-- SECRET SCANNER PAGE -->
296
+ <div id="page-scan" style="display:none">
297
+ <div class="page-header">
298
+ <h1>Secret Scanner</h1>
299
+ <p>Scan directories accessible to OpenClaw for hardcoded secrets using TruffleHog.</p>
300
+ </div>
301
+
302
+ <div class="cards" style="margin-bottom:24px">
303
+ <div class="card"><div class="card-label">TruffleHog</div><div class="card-value" id="scan-thog-status" style="font-size:16px">Checking...</div></div>
304
+ <div class="card"><div class="card-label">Last Scan</div><div class="card-value" id="scan-last-time" style="font-size:16px">Never</div></div>
305
+ <div class="card"><div class="card-label">LIVE Keys</div><div class="card-value" id="scan-verified" style="color:#f87171">-</div></div>
306
+ <div class="card"><div class="card-label">Total Findings</div><div class="card-value" id="scan-total">-</div></div>
307
+ </div>
308
+
309
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:24px">
310
+ <button class="btn" id="scan-run-btn" onclick="runScan()">
311
+ <svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
312
+ Run Scan
313
+ </button>
314
+ <button class="btn" id="scan-solve-btn" onclick="runSolve()" style="display:none">
315
+ <svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
316
+ Scrub Selected
317
+ </button>
318
+ </div>
319
+
320
+ <!-- Scan targets -->
321
+ <div id="scan-targets-section" style="display:none;margin-bottom:24px">
322
+ <h3 style="font-size:14px;font-weight:600;margin-bottom:8px">Scanned Directories</h3>
323
+ <div id="scan-targets-list"></div>
324
+ </div>
325
+
326
+ <!-- Findings table -->
327
+ <div class="table-wrap">
328
+ <div class="table-header">
329
+ <h3>Findings</h3>
330
+ <div class="filter-row">
331
+ <label class="check-label" id="scan-select-all-wrap" style="display:none"><input type="checkbox" id="scan-select-all" onchange="toggleScanSelectAll()"> Select all</label>
332
+ </div>
333
+ </div>
334
+ <div class="table-body" id="scan-findings-body">
335
+ <div class="empty">No scan results yet. Click "Run Scan" to start.</div>
336
+ </div>
337
+ </div>
338
+
339
+ <!-- Solve results -->
340
+ <div id="scan-solve-results" style="display:none;margin-top:24px">
341
+ <div class="table-wrap">
342
+ <div class="table-header"><h3>Scrub Results</h3></div>
343
+ <div class="table-body" id="scan-solve-body"></div>
344
+ </div>
225
345
  </div>
226
346
  </div>
227
347
 
@@ -231,11 +351,35 @@
231
351
  <h1>Skill Scanner</h1>
232
352
  <p>Skills cross-indexed and scanned for prompt injection patterns via Haiku.</p>
233
353
  </div>
354
+
355
+ <!-- Skills Status (top section) -->
356
+ <div class="table-wrap" style="margin-bottom:24px">
357
+ <div class="table-header">
358
+ <h3>Skills Overview</h3>
359
+ </div>
360
+ <div class="table-body" id="skill-status-body">
361
+ <div class="empty">Loading skills...</div>
362
+ </div>
363
+ </div>
364
+
365
+ <!-- File Viewer Modal -->
366
+ <div id="skill-file-modal" style="display:none;position:fixed;inset:0;z-index:50;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;padding:24px">
367
+ <div style="background:#18181b;border:1px solid rgba(255,255,255,0.1);border-radius:8px;width:100%;max-width:720px;max-height:80vh;display:flex;flex-direction:column">
368
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,0.1)">
369
+ <span style="font-size:14px;font-weight:600" id="skill-modal-title">Files</span>
370
+ <button class="btn btn-sm" onclick="closeSkillModal()">&times;</button>
371
+ </div>
372
+ <div id="skill-modal-content" style="overflow:auto;padding:16px;flex:1"></div>
373
+ </div>
374
+ </div>
375
+
376
+ <!-- Activity Log (bottom section) -->
234
377
  <div class="table-wrap">
235
378
  <div class="table-header">
236
- <h3>Events</h3>
379
+ <h3>Activity Log</h3>
237
380
  <div class="filter-row">
238
- <label class="check-label"><input type="checkbox" id="skill-server-only" onchange="renderGuardPage('skill')"> View only server sent events</label>
381
+ <label class="check-label"><input type="checkbox" id="skill-blocked-only" onchange="renderSkillPage()"> Only flagged/blocked</label>
382
+ <label class="check-label"><input type="checkbox" id="skill-server-only" onchange="renderSkillPage()"> Server sent events</label>
239
383
  </div>
240
384
  </div>
241
385
  <div class="table-body" id="skill-table-body"></div>
@@ -252,7 +396,9 @@
252
396
  <div class="table-header">
253
397
  <h3>Events</h3>
254
398
  <div class="filter-row">
255
- <label class="check-label"><input type="checkbox" id="bash-server-only" onchange="renderGuardPage('bash')"> View only server sent events</label>
399
+ <label class="check-label"><input type="checkbox" id="bash-blocked-only" onchange="renderGuardPage('bash')"> Show only blocked</label>
400
+ <label class="check-label"><input type="checkbox" id="bash-show-system" onchange="renderGuardPage('bash')"> Show system commands</label>
401
+ <label class="check-label"><input type="checkbox" id="bash-server-only" onchange="renderGuardPage('bash')"> Server sent events</label>
256
402
  </div>
257
403
  </div>
258
404
  <div class="table-body" id="bash-table-body"></div>
@@ -267,9 +413,10 @@
267
413
  </div>
268
414
  <div class="table-wrap">
269
415
  <div class="table-header">
270
- <h3>Events</h3>
416
+ <h3>Scan Events</h3>
271
417
  <div class="filter-row">
272
- <label class="check-label"><input type="checkbox" id="pi-server-only" onchange="renderGuardPage('pi')"> View only server sent events</label>
418
+ <label class="check-label"><input type="checkbox" id="pi-blocked-only" onchange="renderPiPage()"> Only flagged</label>
419
+ <label class="check-label"><input type="checkbox" id="pi-server-only" onchange="renderPiPage()"> Server sent events</label>
273
420
  </div>
274
421
  </div>
275
422
  <div class="table-body" id="pi-table-body"></div>
@@ -286,13 +433,53 @@
286
433
  <div class="table-header">
287
434
  <h3>Events</h3>
288
435
  <div class="filter-row">
289
- <label class="check-label"><input type="checkbox" id="secrets-server-only" onchange="renderGuardPage('secrets')"> View only server sent events</label>
436
+ <label class="check-label"><input type="checkbox" id="secrets-blocked-only" onchange="renderSecretsPage()"> Only show blocked</label>
437
+ <label class="check-label"><input type="checkbox" id="secrets-server-only" onchange="renderSecretsPage()"> Server sent events</label>
290
438
  </div>
291
439
  </div>
292
440
  <div class="table-body" id="secrets-table-body"></div>
293
441
  </div>
294
442
  </div>
295
443
 
444
+ <!-- EXFIL MONITOR PAGE -->
445
+ <div id="page-exfil" style="display:none">
446
+ <div class="page-header">
447
+ <h1>Secrets Exfil Monitor</h1>
448
+ <p>Detects when sensitive environment variables are transmitted to external servers via curl, wget, or nc.</p>
449
+ </div>
450
+
451
+ <!-- Destination Allowlist -->
452
+ <div class="card" style="margin-bottom:24px;padding:20px">
453
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
454
+ <div>
455
+ <h3 style="font-size:15px;font-weight:600;margin-bottom:4px">Destination Allowlist</h3>
456
+ <div style="font-size:12px;color:#a1a1aa">When enabled, commands sending secrets to domains NOT in this list will be blocked.</div>
457
+ </div>
458
+ <div style="display:flex;gap:8px;align-items:center">
459
+ <span id="exfil-al-status-badge"></span>
460
+ <button class="btn btn-sm" id="exfil-al-toggle-btn" onclick="toggleExfilAllowlist()">Enable</button>
461
+ </div>
462
+ </div>
463
+ <div id="exfil-al-domains" style="margin-bottom:12px"></div>
464
+ <div style="display:flex;gap:8px;align-items:center">
465
+ <input type="text" id="exfil-al-input" placeholder="e.g. api.notion.com or *.supabase.co" style="flex:1;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:8px 12px;color:#e4e4e7;font-size:13px;font-family:ui-monospace,monospace;outline:none" />
466
+ <button class="btn btn-sm" onclick="addExfilDomain()">Add</button>
467
+ </div>
468
+ </div>
469
+
470
+ <div class="table-wrap">
471
+ <div class="table-header">
472
+ <h3>Events</h3>
473
+ <div class="filter-row">
474
+ <label class="check-label"><input type="checkbox" id="exfil-blocked-only" onchange="renderGuardPage('exfil')"> Only show blocked</label>
475
+ <label class="check-label"><input type="checkbox" id="exfil-show-passed" onchange="renderGuardPage('exfil')"> Show passed</label>
476
+ <label class="check-label"><input type="checkbox" id="exfil-server-only" onchange="renderGuardPage('exfil')"> Server sent events</label>
477
+ </div>
478
+ </div>
479
+ <div class="table-body" id="exfil-table-body"></div>
480
+ </div>
481
+ </div>
482
+
296
483
  </main>
297
484
  </div>
298
485
 
@@ -303,19 +490,30 @@ let localEvents = [];
303
490
  let serverEvents = [];
304
491
  let overviewData = {};
305
492
 
306
- // Guard blocker name mapping
307
- const GUARD_BLOCKER = { skill: 'skill', bash: 'tirith', pi: 'prompt_injection', secrets: 'env_var' };
493
+ // Map tab name → guard field value in log events
494
+ const GUARD_KEY = { skill: 'skill', bash: 'tirith', pi: 'prompt_injection', secrets: 'env_var', exfil: 'exfil' };
495
+ // All events for this guard (passed + blocked + special)
308
496
  const GUARD_EVENTS = {
309
- skill: e => e.blocker === 'skill',
310
- bash: e => e.blocker === 'tirith',
311
- pi: e => e.blocker === 'prompt_injection',
312
- secrets: e => e.blocker === 'env_var' || e.event === 'output_redacted',
497
+ skill: e => e.guard === 'skill',
498
+ bash: e => e.guard === 'tirith',
499
+ pi: e => e.guard === 'prompt_injection',
500
+ secrets: e => e.guard === 'env_var' || e.event === 'output_redacted',
501
+ exfil: e => e.guard === 'exfil',
502
+ };
503
+ // Only blocked/flagged/detected events for this guard
504
+ const GUARD_BLOCKED = {
505
+ skill: e => e.guard === 'skill' && (e.decision === 'block' || e.decision === 'scan_flagged' || e.decision === 'binary_detected'),
506
+ bash: e => e.guard === 'tirith' && e.decision === 'block',
507
+ pi: e => e.guard === 'prompt_injection' && (e.decision === 'block' || e.decision === 'scan_flagged'),
508
+ secrets: e => (e.guard === 'env_var' && e.decision === 'block') || e.event === 'output_redacted',
509
+ exfil: e => e.guard === 'exfil',
313
510
  };
314
511
  const SERVER_GUARD = {
315
512
  skill: e => e.destination === 'supabase' || (e.destination === 'posthog' && e.event && e.event.includes('skill')),
316
513
  bash: e => e.destination === 'posthog' && e.properties?.blocker === 'tirith',
317
514
  pi: e => e.destination === 'posthog' && (e.event === 'output_scan_started' || e.event === 'output_scan_result' || e.properties?.blocker === 'prompt_injection'),
318
515
  secrets: e => e.destination === 'posthog' && (e.properties?.blocker === 'env_var_leak' || e.event === 'output_secrets_redacted'),
516
+ exfil: e => e.destination === 'posthog' && e.event === 'exfil_attempt',
319
517
  };
320
518
 
321
519
  function setDays(d) {
@@ -327,10 +525,15 @@ function setDays(d) {
327
525
  function switchPage(page) {
328
526
  currentPage = page;
329
527
  document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.toggle('active', b.dataset.page === page));
330
- ['home','skill','bash','pi','secrets'].forEach(p => {
528
+ ['home','scan','skill','bash','pi','secrets','exfil'].forEach(p => {
331
529
  document.getElementById('page-' + p).style.display = p === page ? '' : 'none';
332
530
  });
333
- if (page !== 'home') renderGuardPage(page);
531
+ if (page === 'scan') loadScanStatus();
532
+ else if (page === 'skill') renderSkillPage();
533
+ else if (page === 'pi') renderPiPage();
534
+ else if (page === 'secrets') renderSecretsPage();
535
+ else if (page === 'exfil') { loadExfilAllowlist(); renderGuardPage('exfil'); }
536
+ else if (page !== 'home') renderGuardPage(page);
334
537
  }
335
538
 
336
539
  async function refresh() {
@@ -345,7 +548,15 @@ async function refresh() {
345
548
  overviewData = ov;
346
549
  renderOverview(ov);
347
550
  renderSidebarDots(ov);
348
- if (currentPage !== 'home') renderGuardPage(currentPage);
551
+ renderBlockBanner();
552
+ checkAnthropicKey();
553
+ if (currentPage === 'scan') loadScanStatus();
554
+ else if (currentPage === 'skill') renderSkillPage();
555
+ else if (currentPage === 'pi') renderPiPage();
556
+ else if (currentPage === 'secrets') renderSecretsPage();
557
+ else if (currentPage === 'exfil') { loadExfilAllowlist(); renderGuardPage('exfil'); }
558
+ else if (currentPage !== 'home') renderGuardPage(currentPage);
559
+ loadScanStatus(); // always update home scan summary
349
560
  document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
350
561
  } catch (e) {
351
562
  console.error('Refresh failed:', e);
@@ -365,12 +576,14 @@ function renderOverview(ov) {
365
576
  document.getElementById('g-pi-blocks').textContent = gs.prompt_injection?.blocks || 0;
366
577
  document.getElementById('g-sec-blocks').textContent = gs.secrets_guard?.blocks || 0;
367
578
  document.getElementById('g-sec-redactions').textContent = gs.secrets_guard?.redactions || 0;
579
+ document.getElementById('g-exfil-detections').textContent = gs.exfil_monitor?.detections || 0;
368
580
 
369
581
  const hasData = ov.total > 0;
370
582
  setDot('g-skill-dot', hasData, gs.skill_scanner?.blocks > 0);
371
583
  setDot('g-bash-dot', hasData, gs.bash_guard?.blocks > 0);
372
584
  setDot('g-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
373
585
  setDot('g-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
586
+ setDot('g-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
374
587
  }
375
588
 
376
589
  function renderSidebarDots(ov) {
@@ -380,20 +593,576 @@ function renderSidebarDots(ov) {
380
593
  setDot('sb-bash-dot', hasData, gs.bash_guard?.blocks > 0);
381
594
  setDot('sb-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
382
595
  setDot('sb-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
596
+ setDot('sb-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
383
597
  }
384
598
 
385
- function setDot(id, hasData, hasBlocks) {
599
+ function setDot(id, hasData, hasBlocks, activeClass) {
386
600
  const el = document.getElementById(id);
387
601
  if (!el) return;
388
- el.className = 'dot ' + (hasData ? (hasBlocks ? 'dot-red' : 'dot-green') : 'dot-gray');
602
+ el.className = 'dot ' + (hasData ? (hasBlocks ? (activeClass || 'dot-red') : 'dot-green') : 'dot-gray');
603
+ }
604
+
605
+ // =============================================
606
+ // Home — Active Block Banner
607
+ // =============================================
608
+ function renderBlockBanner() {
609
+ const banner = document.getElementById('home-block-banner');
610
+ if (!banner) return;
611
+
612
+ // Find the most recent block event (scan_flagged from skill or prompt_injection, or binary_detected)
613
+ const blockEvent = localEvents.find(e =>
614
+ e.event === 'guard_check' &&
615
+ ((e.guard === 'skill' && (e.decision === 'scan_flagged' || e.decision === 'binary_detected')) ||
616
+ (e.guard === 'prompt_injection' && e.decision === 'scan_flagged'))
617
+ );
618
+
619
+ // Check if block was already removed
620
+ const unblockEvent = localEvents.find(e => e.event === 'block_removed');
621
+ if (unblockEvent && blockEvent && new Date(unblockEvent.ts) > new Date(blockEvent.ts)) {
622
+ banner.style.display = 'none';
623
+ return;
624
+ }
625
+
626
+ if (!blockEvent) {
627
+ banner.style.display = 'none';
628
+ return;
629
+ }
630
+
631
+ banner.style.display = '';
632
+ const guard = blockEvent.guard === 'skill' ? 'Skill Scanner' : 'Prompt Injection';
633
+ const guardPage = blockEvent.guard === 'skill' ? 'skill' : 'pi';
634
+ document.getElementById('home-block-title').textContent = `Agent Blocked — ${guard}`;
635
+ document.getElementById('home-block-reason').textContent = blockEvent.reason || 'A security threat was detected. All commands are blocked.';
636
+ document.getElementById('home-block-view-btn').setAttribute('onclick', `switchPage('${guardPage}')`);
637
+ }
638
+
639
+ async function removeBlock() {
640
+ if (!confirm('Remove the active block and allow OpenClaw to proceed?\n\nOnly do this if you have reviewed and resolved the security issue.')) return;
641
+ try {
642
+ const res = await fetch('/api/unblock', { method: 'POST' });
643
+ const data = await res.json();
644
+ if (data.error) { alert('Error: ' + data.error); return; }
645
+ document.getElementById('home-block-banner').style.display = 'none';
646
+ refresh();
647
+ } catch (e) {
648
+ alert('Failed to remove block: ' + e.message);
649
+ }
650
+ }
651
+
652
+ // =============================================
653
+ // Anthropic API Key Box
654
+ // =============================================
655
+ async function checkAnthropicKey() {
656
+ try {
657
+ const res = await fetch('/api/anthropic-key');
658
+ const data = await res.json();
659
+ const box = document.getElementById('anthropic-key-box');
660
+ if (!data.hasKey) {
661
+ box.style.display = '';
662
+ } else {
663
+ box.style.display = 'none';
664
+ }
665
+ } catch {}
666
+ }
667
+
668
+ async function saveAnthropicKey() {
669
+ const input = document.getElementById('anthropic-key-input');
670
+ const msg = document.getElementById('anthropic-key-msg');
671
+ const key = input.value.trim();
672
+ if (!key) return;
673
+ try {
674
+ const res = await fetch('/api/anthropic-key', {
675
+ method: 'POST',
676
+ headers: { 'Content-Type': 'application/json' },
677
+ body: JSON.stringify({ key }),
678
+ });
679
+ const data = await res.json();
680
+ msg.style.display = '';
681
+ if (data.error) {
682
+ msg.style.color = '#f87171';
683
+ msg.textContent = data.error;
684
+ } else {
685
+ msg.style.color = '#4ade80';
686
+ msg.textContent = 'Key saved. Restart openclaw for prompt injection detection to activate.';
687
+ input.value = '';
688
+ setTimeout(() => { document.getElementById('anthropic-key-box').style.display = 'none'; }, 3000);
689
+ }
690
+ } catch (e) {
691
+ msg.style.display = '';
692
+ msg.style.color = '#f87171';
693
+ msg.textContent = 'Failed: ' + e.message;
694
+ }
695
+ }
696
+
697
+ // =============================================
698
+ // Skill Scanner Page
699
+ // =============================================
700
+ function renderSkillPage() {
701
+ renderSkillStatus();
702
+ renderSkillActivityLog();
703
+ }
704
+
705
+ function renderSkillStatus() {
706
+ const container = document.getElementById('skill-status-body');
707
+ // Get skill events that tell us about skills (init_skill, scanning, scan_clean, scan_flagged, binary_detected, removed, modified, deleted)
708
+ const skillEvents = localEvents.filter(e => e.guard === 'skill' && e.decision !== 'allow' && e.decision !== 'init');
709
+
710
+ // Build latest status per skill by path
711
+ const skillMap = new Map();
712
+ // Process in chronological order (events are newest-first, so reverse)
713
+ const chronological = [...skillEvents].reverse();
714
+ for (const e of chronological) {
715
+ const sp = e.detail?.skill_path;
716
+ if (!sp) continue;
717
+ const name = e.detail?.skill_name || sp.split('/').pop();
718
+ if (!skillMap.has(sp)) {
719
+ skillMap.set(sp, { name, path: sp, dir: e.detail?.skill_dir || '', status: 'clean', reason: '', fileCount: 0, fileNames: [], binaryFiles: [], fileContents: [], ts: e.ts, decision: e.decision });
720
+ }
721
+ const skill = skillMap.get(sp);
722
+ skill.ts = e.ts; // update to latest timestamp
723
+ skill.decision = e.decision;
724
+ if (e.detail?.file_count !== undefined) skill.fileCount = e.detail.file_count;
725
+ if (e.detail?.file_names) skill.fileNames = e.detail.file_names;
726
+ if (e.detail?.binary_files) skill.binaryFiles = e.detail.binary_files;
727
+ if (e.detail?.file_contents) skill.fileContents = e.detail.file_contents;
728
+ // Determine status
729
+ if (e.decision === 'scan_flagged') { skill.status = 'malicious'; skill.reason = e.reason || ''; }
730
+ else if (e.decision === 'binary_detected') { skill.status = 'binary'; skill.reason = e.reason || ''; }
731
+ else if (e.decision === 'scan_clean') { skill.status = 'clean'; skill.reason = ''; }
732
+ else if (e.decision === 'removed' || e.decision === 'deleted') { skill.status = 'removed'; skill.reason = ''; }
733
+ else if (e.decision === 'modified') { skill.status = 'scanning'; }
734
+ else if (e.decision === 'scanning') { skill.status = 'scanning'; }
735
+ else if (e.decision === 'init_skill') {
736
+ skill.status = e.detail?.status || 'clean';
737
+ skill.reason = e.detail?.flagged_reason || '';
738
+ }
739
+ }
740
+
741
+ const skills = Array.from(skillMap.values()).filter(s => s.status !== 'removed');
742
+
743
+ if (skills.length === 0) {
744
+ container.innerHTML = '<div class="empty">No skills discovered yet. Skills appear when openclaw-secure is active.</div>';
745
+ return;
746
+ }
747
+
748
+ container.innerHTML = `<table><thead><tr>
749
+ <th style="width:40px"></th>
750
+ <th>Skill</th>
751
+ <th>Path</th>
752
+ <th>Status</th>
753
+ <th>Reason</th>
754
+ <th style="text-align:center">Files</th>
755
+ <th>Last Scanned</th>
756
+ </tr></thead><tbody>` +
757
+ skills.map((s, i) => {
758
+ const statusBadge = s.status === 'malicious'
759
+ ? '<span class="badge badge-red">Malicious</span>'
760
+ : s.status === 'binary'
761
+ ? '<span class="badge badge-red">Binary</span>'
762
+ : s.status === 'scanning'
763
+ ? '<span class="badge badge-blue">Scanning</span>'
764
+ : '<span class="badge badge-green">Clean</span>';
765
+ const deleteBtn = (s.status === 'malicious' || s.status === 'binary')
766
+ ? `<button class="btn btn-sm" style="color:#f87171;border-color:rgba(239,68,68,0.3)" onclick="deleteSkill('${esc(s.path.replace(/'/g, "\\'"))}')">Delete</button>`
767
+ : '';
768
+ const viewFiles = s.fileNames.length > 0 || s.fileContents.length > 0
769
+ ? `<span class="payload-toggle" onclick="openSkillFiles(${i})">${s.fileCount || s.fileNames.length}</span>`
770
+ : `<span style="color:#a1a1aa">${s.fileCount || 0}</span>`;
771
+ return `<tr>
772
+ <td>${deleteBtn}</td>
773
+ <td style="font-weight:500;font-family:ui-monospace,monospace;font-size:13px">${esc(s.name)}</td>
774
+ <td style="font-size:12px;color:#a1a1aa;max-width:250px;word-break:break-all">${esc(s.path)}</td>
775
+ <td>${statusBadge}</td>
776
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${esc(s.reason)}</td>
777
+ <td style="text-align:center">${viewFiles}</td>
778
+ <td style="white-space:nowrap;color:#a1a1aa;font-size:12px">${formatTime(s.ts)}</td>
779
+ </tr>`;
780
+ }).join('') + '</tbody></table>';
781
+
782
+ // Store skills data for file modal
783
+ window._skillStatusData = skills;
784
+ }
785
+
786
+ function openSkillFiles(idx) {
787
+ const skill = window._skillStatusData?.[idx];
788
+ if (!skill) return;
789
+ const modal = document.getElementById('skill-file-modal');
790
+ const title = document.getElementById('skill-modal-title');
791
+ const content = document.getElementById('skill-modal-content');
792
+ title.textContent = 'Files in ' + skill.name;
793
+
794
+ if (skill.fileContents && skill.fileContents.length > 0) {
795
+ content.innerHTML = skill.fileContents.map(f =>
796
+ `<div style="border:1px solid rgba(255,255,255,0.1);border-radius:6px;overflow:hidden;margin-bottom:12px">
797
+ <div style="background:rgba(255,255,255,0.05);padding:6px 12px;font-family:ui-monospace,monospace;font-size:12px;color:#a1a1aa;border-bottom:1px solid rgba(255,255,255,0.1)">${esc(f.path)}</div>
798
+ <pre style="padding:8px 12px;font-size:12px;font-family:ui-monospace,monospace;white-space:pre-wrap;word-break:break-all;max-height:256px;overflow:auto;margin:0">${esc(f.content)}</pre>
799
+ </div>`
800
+ ).join('');
801
+ } else if (skill.fileNames && skill.fileNames.length > 0) {
802
+ content.innerHTML = '<div style="padding:8px 0">' + skill.fileNames.map(f =>
803
+ `<div style="padding:4px 0;font-family:ui-monospace,monospace;font-size:13px;color:#a1a1aa">${esc(f)}</div>`
804
+ ).join('') + '</div>';
805
+ } else {
806
+ content.innerHTML = '<div class="empty">No file data available for this skill.</div>';
807
+ }
808
+
809
+ modal.style.display = 'flex';
810
+ }
811
+
812
+ function closeSkillModal() {
813
+ document.getElementById('skill-file-modal').style.display = 'none';
814
+ }
815
+
816
+ async function deleteSkill(skillPath) {
817
+ if (!confirm('Delete this skill directory permanently?\n\n' + skillPath + '\n\nThis cannot be undone.')) return;
818
+ try {
819
+ const res = await fetch('/api/skill/delete', {
820
+ method: 'POST',
821
+ headers: { 'Content-Type': 'application/json' },
822
+ body: JSON.stringify({ skillPath }),
823
+ });
824
+ const data = await res.json();
825
+ if (data.error) { alert('Error: ' + data.error); return; }
826
+ // Refresh
827
+ refresh();
828
+ } catch (e) {
829
+ alert('Failed to delete skill: ' + e.message);
830
+ }
831
+ }
832
+
833
+ function renderSkillActivityLog() {
834
+ const serverOnly = document.getElementById('skill-server-only')?.checked;
835
+ const blockedOnly = document.getElementById('skill-blocked-only')?.checked;
836
+ const container = document.getElementById('skill-table-body');
837
+
838
+ if (serverOnly) {
839
+ const filtered = serverEvents.filter(SERVER_GUARD.skill || (() => false));
840
+ if (filtered.length === 0) {
841
+ container.innerHTML = '<div class="empty">No server send events for skill scanner.</div>';
842
+ return;
843
+ }
844
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Destination</th><th>Event</th><th>Payload</th></tr></thead><tbody>` +
845
+ filtered.map((e, i) => {
846
+ const dest = e.destination === 'posthog' ? '<span class="badge badge-blue">PostHog</span>' : '<span class="badge badge-yellow">Supabase</span>';
847
+ const payloadStr = JSON.stringify(e.properties || e.payload || {}, null, 2);
848
+ const uid = 'skill-sv-' + i;
849
+ return `<tr>
850
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
851
+ <td>${dest}</td>
852
+ <td>${esc(e.event || '-')}</td>
853
+ <td><span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View payload</span><div class="payload-content" id="${uid}">${esc(payloadStr)}</div></td>
854
+ </tr>`;
855
+ }).join('') + '</tbody></table>';
856
+ } else {
857
+ // Show all skill-specific events (not "allow" per-command checks)
858
+ const skillDecisions = new Set(['init', 'init_skill', 'scanning', 'scan_clean', 'scan_flagged', 'binary_detected', 'removed', 'modified', 'deleted']);
859
+ let filtered = localEvents.filter(e => e.guard === 'skill' && skillDecisions.has(e.decision));
860
+ if (blockedOnly) {
861
+ filtered = filtered.filter(e => e.decision === 'scan_flagged' || e.decision === 'binary_detected');
862
+ }
863
+ if (filtered.length === 0) {
864
+ container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No flagged skill events.' : 'No skill activity yet.') + '</div>';
865
+ return;
866
+ }
867
+ container.innerHTML = `<table><thead><tr>
868
+ <th>Time</th>
869
+ <th>Event</th>
870
+ <th>Skill</th>
871
+ <th>Path</th>
872
+ <th>Files</th>
873
+ <th>Detail</th>
874
+ </tr></thead><tbody>` +
875
+ filtered.map((e, i) => {
876
+ const badgeMap = { init: 'badge-gray', init_skill: 'badge-gray', scanning: 'badge-blue', scan_clean: 'badge-green', scan_flagged: 'badge-red', binary_detected: 'badge-red', removed: 'badge-yellow', modified: 'badge-blue', deleted: 'badge-yellow' };
877
+ const labelMap = { init: 'Init', init_skill: 'Discovered', scanning: 'Scanning', scan_clean: 'Clean', scan_flagged: 'Flagged', binary_detected: 'Binary', removed: 'Removed', modified: 'Modified', deleted: 'Deleted' };
878
+ const badge = badgeMap[e.decision] || 'badge-gray';
879
+ const label = labelMap[e.decision] || e.decision;
880
+ const skillName = e.detail?.skill_name || '-';
881
+ const skillPath = e.detail?.skill_path || '';
882
+ const fileCount = e.detail?.file_count ?? e.detail?.file_names?.length ?? '';
883
+ const fileNames = e.detail?.file_names || [];
884
+ const binaryFiles = e.detail?.binary_files || [];
885
+ const uid = 'skill-log-' + i;
886
+ // Build detail string
887
+ let detailParts = [];
888
+ if (e.reason && e.decision !== 'init') detailParts.push(esc(e.reason));
889
+ let detailExtra = '';
890
+ if (fileNames.length > 0 || binaryFiles.length > 0 || e.detail) {
891
+ const detailObj = {};
892
+ if (fileNames.length > 0) detailObj.files = fileNames;
893
+ if (binaryFiles.length > 0) detailObj.binary_files = binaryFiles;
894
+ if (e.detail?.directories) detailObj.directories = e.detail.directories;
895
+ if (e.detail?.cached !== undefined) detailObj.cached = e.detail.cached;
896
+ if (Object.keys(detailObj).length > 0) {
897
+ detailExtra = ` <span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(JSON.stringify(detailObj, null, 2))}</div>`;
898
+ }
899
+ }
900
+ return `<tr>
901
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
902
+ <td><span class="badge ${badge}">${label}</span></td>
903
+ <td style="font-family:ui-monospace,monospace;font-size:13px">${esc(skillName)}</td>
904
+ <td style="font-size:12px;color:#a1a1aa;max-width:200px;word-break:break-all">${esc(skillPath)}</td>
905
+ <td style="text-align:center;color:#a1a1aa">${fileCount}</td>
906
+ <td style="font-size:12px;color:#a1a1aa;max-width:350px">${detailParts.join('') + detailExtra}</td>
907
+ </tr>`;
908
+ }).join('') + '</tbody></table>';
909
+ }
389
910
  }
390
911
 
912
+ // =============================================
913
+ // Prompt Injection Page
914
+ // =============================================
915
+ function renderPiPage() {
916
+ const serverOnly = document.getElementById('pi-server-only')?.checked;
917
+ const blockedOnly = document.getElementById('pi-blocked-only')?.checked;
918
+ const container = document.getElementById('pi-table-body');
919
+
920
+ if (serverOnly) {
921
+ const filtered = serverEvents.filter(SERVER_GUARD.pi || (() => false));
922
+ if (filtered.length === 0) {
923
+ container.innerHTML = '<div class="empty">No server send events for prompt injection guard.</div>';
924
+ return;
925
+ }
926
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Destination</th><th>Event</th><th>Payload</th></tr></thead><tbody>` +
927
+ filtered.map((e, i) => {
928
+ const dest = e.destination === 'posthog' ? '<span class="badge badge-blue">PostHog</span>' : '<span class="badge badge-yellow">Supabase</span>';
929
+ const payloadStr = JSON.stringify(e.properties || e.payload || {}, null, 2);
930
+ const uid = 'pi-sv-' + i;
931
+ return `<tr>
932
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
933
+ <td>${dest}</td>
934
+ <td>${esc(e.event || '-')}</td>
935
+ <td><span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View payload</span><div class="payload-content" id="${uid}">${esc(payloadStr)}</div></td>
936
+ </tr>`;
937
+ }).join('') + '</tbody></table>';
938
+ } else {
939
+ // Show only PI-specific scan events (not per-command allow checks)
940
+ const piDecisions = new Set(['scanning', 'scan_clean', 'scan_flagged']);
941
+ let filtered = localEvents.filter(e => e.guard === 'prompt_injection' && piDecisions.has(e.decision));
942
+ if (blockedOnly) {
943
+ filtered = filtered.filter(e => e.decision === 'scan_flagged');
944
+ }
945
+ if (filtered.length === 0) {
946
+ container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No flagged prompt injection events.' : 'No prompt injection scans yet. Scans trigger when matching command output is detected.') + '</div>';
947
+ return;
948
+ }
949
+ container.innerHTML = `<table><thead><tr>
950
+ <th>Time</th>
951
+ <th>Decision</th>
952
+ <th>Pattern Matched</th>
953
+ <th>Command</th>
954
+ <th>Haiku Decision</th>
955
+ <th>Haiku Reason</th>
956
+ <th>I/O</th>
957
+ </tr></thead><tbody>` +
958
+ filtered.map((e, i) => {
959
+ const badgeMap = { scanning: 'badge-blue', scan_clean: 'badge-green', scan_flagged: 'badge-red' };
960
+ const labelMap = { scanning: 'Scanning', scan_clean: 'Clean', scan_flagged: 'Flagged' };
961
+ const badge = badgeMap[e.decision] || 'badge-gray';
962
+ const label = labelMap[e.decision] || e.decision;
963
+ const pattern = e.detail?.matched_pattern || '-';
964
+ const modelOutput = e.detail?.model_output;
965
+ const haikuDecision = modelOutput ? (modelOutput.suspicious ? '<span class="badge badge-red">Suspicious</span>' : '<span class="badge badge-green">Clean</span>') : (e.decision === 'scanning' ? '<span class="badge badge-blue">Pending</span>' : '-');
966
+ const haikuReason = modelOutput?.reason || '-';
967
+ // I/O toggle
968
+ const uid = 'pi-io-' + i;
969
+ const modelInput = e.detail?.model_input || '';
970
+ const modelOutputStr = modelOutput ? JSON.stringify(modelOutput, null, 2) : '';
971
+ const hasIO = modelInput || modelOutputStr;
972
+ const ioHtml = hasIO
973
+ ? `<span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View</span><div class="payload-content" id="${uid}"><b>Input (command output):</b>\n${esc(typeof modelInput === 'string' ? modelInput.slice(0, 3000) : JSON.stringify(modelInput).slice(0, 3000))}\n\n<b>Haiku response:</b>\n${esc(modelOutputStr)}</div>`
974
+ : '-';
975
+ return `<tr>
976
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
977
+ <td><span class="badge ${badge}">${label}</span></td>
978
+ <td style="font-family:ui-monospace,monospace;font-size:12px;max-width:200px;word-break:break-all">${esc(pattern)}</td>
979
+ <td class="cmd" style="max-width:250px" title="${esc(e.command || '')}">${esc(e.command || '-')}</td>
980
+ <td>${haikuDecision}</td>
981
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${esc(haikuReason)}</td>
982
+ <td>${ioHtml}</td>
983
+ </tr>`;
984
+ }).join('') + '</tbody></table>';
985
+ }
986
+ }
987
+
988
+ // =============================================
989
+ // Secrets Guard Page
990
+ // =============================================
991
+ function renderSecretsPage() {
992
+ const serverOnly = document.getElementById('secrets-server-only')?.checked;
993
+ const blockedOnly = document.getElementById('secrets-blocked-only')?.checked;
994
+ const container = document.getElementById('secrets-table-body');
995
+
996
+ if (serverOnly) {
997
+ const filtered = serverEvents.filter(SERVER_GUARD.secrets || (() => false));
998
+ if (filtered.length === 0) {
999
+ container.innerHTML = '<div class="empty">No server send events for secrets guard.</div>';
1000
+ return;
1001
+ }
1002
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Destination</th><th>Event</th><th>Payload</th></tr></thead><tbody>` +
1003
+ filtered.map((e, i) => {
1004
+ const dest = e.destination === 'posthog' ? '<span class="badge badge-blue">PostHog</span>' : '<span class="badge badge-yellow">Supabase</span>';
1005
+ const payloadStr = JSON.stringify(e.properties || e.payload || {}, null, 2);
1006
+ const uid = 'sec-sv-' + i;
1007
+ return `<tr>
1008
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1009
+ <td>${dest}</td>
1010
+ <td>${esc(e.event || '-')}</td>
1011
+ <td><span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">View payload</span><div class="payload-content" id="${uid}">${esc(payloadStr)}</div></td>
1012
+ </tr>`;
1013
+ }).join('') + '</tbody></table>';
1014
+ } else {
1015
+ let filtered = localEvents.filter(e => (e.guard === 'env_var' || e.event === 'output_redacted') && e.decision !== 'allow');
1016
+ if (blockedOnly) {
1017
+ filtered = filtered.filter(e => e.decision === 'block' || e.event === 'output_redacted');
1018
+ }
1019
+ // Hide system commands
1020
+ filtered = filtered.filter(e => !e.command || !/^(networksetup|arp|defaults)\b/.test(e.command.trim()));
1021
+ if (filtered.length === 0) {
1022
+ container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No blocked/redacted events.' : 'No secrets guard events yet.') + '</div>';
1023
+ return;
1024
+ }
1025
+ container.innerHTML = `<table><thead><tr>
1026
+ <th>Time</th>
1027
+ <th>Decision</th>
1028
+ <th>Type</th>
1029
+ <th>Pattern Matched</th>
1030
+ <th>Vars / Secrets</th>
1031
+ <th>Command</th>
1032
+ <th>Reason</th>
1033
+ </tr></thead><tbody>` +
1034
+ filtered.map((e, i) => {
1035
+ const badgeMap = { block: 'badge-red', redact: 'badge-yellow', log: 'badge-yellow', allow: 'badge-green' };
1036
+ const labelMap = { block: 'Blocked', redact: 'Redacted', log: 'Logged', allow: 'Allowed' };
1037
+ const badge = badgeMap[e.decision] || 'badge-gray';
1038
+ const label = labelMap[e.decision] || (e.decision || '-');
1039
+
1040
+ // Type column
1041
+ const type = e.detail?.type || (e.event === 'output_redacted' ? 'output_redacted' : '-');
1042
+ const typeLabel = { env_dump: 'Env Dump', value_exposed: 'Value Exposed', lang_env_access: 'Lang API', env_ref_logged: 'Env Ref', output_redacted: 'Output Redacted' }[type] || type;
1043
+
1044
+ // Pattern matched
1045
+ const pattern = e.detail?.matched_pattern || (e.detail?.matched_patterns ? e.detail.matched_patterns.join(', ') : (e.detail?.pattern_category || '-'));
1046
+
1047
+ // Vars / secrets
1048
+ const vars = e.detail?.vars || e.detail?.secrets || [];
1049
+ const varsStr = Array.isArray(vars) ? vars.join(', ') : String(vars);
1050
+
1051
+ const uid = 'sec-lc-' + i;
1052
+ const detailObj = e.detail ? JSON.stringify(e.detail, null, 2) : '';
1053
+ const detailHtml = detailObj ? ` <span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(detailObj)}</div>` : '';
1054
+
1055
+ return `<tr>
1056
+ <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
1057
+ <td><span class="badge ${badge}">${label}</span></td>
1058
+ <td style="font-size:12px"><span class="badge badge-gray">${esc(typeLabel)}</span></td>
1059
+ <td style="font-family:ui-monospace,monospace;font-size:11px;max-width:200px;word-break:break-all">${esc(pattern)}</td>
1060
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#facc15;max-width:180px;word-break:break-all">${esc(varsStr)}</td>
1061
+ <td class="cmd" style="max-width:250px" title="${esc(e.command || '')}">${esc(e.command || '-')}</td>
1062
+ <td style="font-size:12px;color:#a1a1aa;max-width:300px">${esc(e.reason || '-')}${detailHtml}</td>
1063
+ </tr>`;
1064
+ }).join('') + '</tbody></table>';
1065
+ }
1066
+ }
1067
+
1068
+ // =============================================
1069
+ // Exfil Allowlist
1070
+ // =============================================
1071
+ let exfilAllowlist = { enabled: false, domains: [] };
1072
+
1073
+ async function loadExfilAllowlist() {
1074
+ try {
1075
+ const res = await fetch('/api/exfil-allowlist');
1076
+ exfilAllowlist = await res.json();
1077
+ } catch { exfilAllowlist = { enabled: false, domains: [] }; }
1078
+ renderExfilAllowlist();
1079
+ }
1080
+
1081
+ function renderExfilAllowlist() {
1082
+ const al = exfilAllowlist || { enabled: false, domains: [] };
1083
+ const statusBadge = document.getElementById('exfil-al-status-badge');
1084
+ const toggleBtn = document.getElementById('exfil-al-toggle-btn');
1085
+ const domainsDiv = document.getElementById('exfil-al-domains');
1086
+ if (!statusBadge) return;
1087
+
1088
+ if (al.enabled) {
1089
+ statusBadge.innerHTML = '<span class="badge badge-green">Enabled</span>';
1090
+ toggleBtn.textContent = 'Disable';
1091
+ toggleBtn.style.color = '#facc15';
1092
+ toggleBtn.style.borderColor = 'rgba(250,204,21,0.3)';
1093
+ } else {
1094
+ statusBadge.innerHTML = '<span class="badge badge-gray">Disabled</span>';
1095
+ toggleBtn.textContent = 'Enable';
1096
+ toggleBtn.style.color = '#4ade80';
1097
+ toggleBtn.style.borderColor = 'rgba(34,197,94,0.3)';
1098
+ }
1099
+
1100
+ if (al.domains.length === 0) {
1101
+ domainsDiv.innerHTML = '<div style="font-size:13px;color:#52525b;padding:8px 0">No domains configured.</div>';
1102
+ } else {
1103
+ domainsDiv.innerHTML = al.domains.map(d =>
1104
+ `<div style="display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:4px 10px;margin:3px 4px 3px 0;font-size:13px;font-family:ui-monospace,monospace">
1105
+ ${esc(d)}
1106
+ <span style="cursor:pointer;color:#a1a1aa;font-size:16px;line-height:1" onclick="removeExfilDomain('${esc(d)}')" title="Remove">&times;</span>
1107
+ </div>`
1108
+ ).join('');
1109
+ }
1110
+ }
1111
+
1112
+ async function addExfilDomain() {
1113
+ const input = document.getElementById('exfil-al-input');
1114
+ const domain = (input.value || '').trim();
1115
+ if (!domain) return;
1116
+ try {
1117
+ const res = await fetch('/api/exfil-allowlist', {
1118
+ method: 'POST',
1119
+ headers: { 'Content-Type': 'application/json' },
1120
+ body: JSON.stringify({ action: 'add', domain }),
1121
+ });
1122
+ const data = await res.json();
1123
+ if (data.allowlist) exfilAllowlist = data.allowlist;
1124
+ input.value = '';
1125
+ renderExfilAllowlist();
1126
+ } catch (e) { alert('Failed: ' + e.message); }
1127
+ }
1128
+
1129
+ async function removeExfilDomain(domain) {
1130
+ try {
1131
+ const res = await fetch('/api/exfil-allowlist', {
1132
+ method: 'POST',
1133
+ headers: { 'Content-Type': 'application/json' },
1134
+ body: JSON.stringify({ action: 'remove', domain }),
1135
+ });
1136
+ const data = await res.json();
1137
+ if (data.allowlist) exfilAllowlist = data.allowlist;
1138
+ renderExfilAllowlist();
1139
+ } catch (e) { alert('Failed: ' + e.message); }
1140
+ }
1141
+
1142
+ async function toggleExfilAllowlist() {
1143
+ const action = exfilAllowlist.enabled ? 'disable' : 'enable';
1144
+ try {
1145
+ const res = await fetch('/api/exfil-allowlist', {
1146
+ method: 'POST',
1147
+ headers: { 'Content-Type': 'application/json' },
1148
+ body: JSON.stringify({ action }),
1149
+ });
1150
+ const data = await res.json();
1151
+ if (data.allowlist) exfilAllowlist = data.allowlist;
1152
+ renderExfilAllowlist();
1153
+ } catch (e) { alert('Failed: ' + e.message); }
1154
+ }
1155
+
1156
+ const SYSTEM_COMMANDS = /^(networksetup|arp|defaults)\b/;
1157
+
391
1158
  function renderGuardPage(guard) {
392
1159
  const serverOnly = document.getElementById(guard + '-server-only')?.checked;
1160
+ const blockedOnly = document.getElementById(guard + '-blocked-only')?.checked;
1161
+ const showPassed = document.getElementById(guard + '-show-passed')?.checked;
1162
+ const showSystem = document.getElementById(guard + '-show-system')?.checked;
393
1163
  const container = document.getElementById(guard + '-table-body');
394
1164
 
395
1165
  if (serverOnly) {
396
- // Show server send events filtered to this guard
397
1166
  const filtered = serverEvents.filter(SERVER_GUARD[guard] || (() => false));
398
1167
  if (filtered.length === 0) {
399
1168
  container.innerHTML = '<div class="empty">No server send events for this guard.</div>';
@@ -412,23 +1181,38 @@ function renderGuardPage(guard) {
412
1181
  </tr>`;
413
1182
  }).join('') + '</tbody></table>';
414
1183
  } else {
415
- // Show local events filtered to this guard
416
- const filterFn = GUARD_EVENTS[guard] || (() => false);
417
- const filtered = localEvents.filter(e => filterFn(e));
1184
+ const guardAllFn = GUARD_EVENTS[guard] || (() => false);
1185
+ const guardBlockedFn = GUARD_BLOCKED[guard] || (() => false);
1186
+ let filtered;
1187
+ if (blockedOnly) {
1188
+ filtered = localEvents.filter(e => guardBlockedFn(e));
1189
+ } else {
1190
+ filtered = localEvents.filter(e => guardAllFn(e));
1191
+ }
1192
+ // Hide system commands (networksetup, arp, defaults) unless checkbox is checked
1193
+ if (!showSystem) {
1194
+ filtered = filtered.filter(e => !e.command || !SYSTEM_COMMANDS.test(e.command.trim()));
1195
+ }
418
1196
  if (filtered.length === 0) {
419
- container.innerHTML = '<div class="empty">No events for this guard yet. Events appear when openclaw-secure is active.</div>';
1197
+ container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No blocked events for this guard.' : 'No events yet. Events appear when openclaw-secure is active.') + '</div>';
420
1198
  return;
421
1199
  }
422
- container.innerHTML = `<table><thead><tr><th>Time</th><th>Decision</th><th>Command</th><th>Detail</th></tr></thead><tbody>` +
423
- filtered.map(e => {
424
- const badge = e.decision === 'block' ? 'badge-red' : e.decision === 'redact' ? 'badge-yellow' : 'badge-green';
425
- const label = e.decision === 'block' ? 'Blocked' : e.decision === 'redact' ? 'Redacted' : 'Allowed';
426
- const detail = e.reason ? esc(e.reason) : (e.secrets_count ? `${e.secrets_count} secret(s) redacted` : '');
1200
+ const cmdHeader = guard === 'skill' ? 'Skill File' : 'Command';
1201
+ container.innerHTML = `<table><thead><tr><th>Time</th><th>Decision</th><th>${cmdHeader}</th><th>Detail</th></tr></thead><tbody>` +
1202
+ filtered.map((e, i) => {
1203
+ const badgeMap = { block: 'badge-red', scan_flagged: 'badge-red', binary_detected: 'badge-red', redact: 'badge-yellow', log: 'badge-yellow', scanning: 'badge-blue', scan_clean: 'badge-green', allow: 'badge-green', init: 'badge-gray' };
1204
+ const labelMap = { block: 'Blocked', scan_flagged: 'Flagged', binary_detected: 'Binary', redact: 'Redacted', log: 'Logged', scanning: 'Scanning', scan_clean: 'Clean', allow: 'Allowed', init: 'Init' };
1205
+ const badge = badgeMap[e.decision] || 'badge-gray';
1206
+ const label = labelMap[e.decision] || (e.decision || '-');
1207
+ const reason = e.reason ? esc(e.reason) : (e.secrets_count ? `${e.secrets_count} secret(s) redacted` : '');
1208
+ const detailObj = e.detail ? JSON.stringify(e.detail, null, 2) : '';
1209
+ const uid = guard + '-lc-' + i;
1210
+ const detailHtml = reason + (detailObj ? ` <span class="payload-toggle" onclick="document.getElementById('${uid}').classList.toggle('open')">[detail]</span><div class="payload-content" id="${uid}">${esc(detailObj)}</div>` : '');
427
1211
  return `<tr>
428
1212
  <td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
429
1213
  <td><span class="badge ${badge}">${label}</span></td>
430
1214
  <td class="cmd" title="${esc(e.command || '')}">${esc(e.command || '-')}</td>
431
- <td style="font-size:12px;color:#a1a1aa;max-width:300px;overflow:hidden;text-overflow:ellipsis">${detail}</td>
1215
+ <td style="font-size:12px;color:#a1a1aa;max-width:400px">${detailHtml}</td>
432
1216
  </tr>`;
433
1217
  }).join('') + '</tbody></table>';
434
1218
  }
@@ -457,8 +1241,236 @@ function esc(s) {
457
1241
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
458
1242
  }
459
1243
 
1244
+ // =============================================
1245
+ // Secret Scanner
1246
+ // =============================================
1247
+ let scanData = null; // last scan results from API
1248
+
1249
+ async function loadScanStatus() {
1250
+ try {
1251
+ const res = await fetch('/api/scan');
1252
+ const data = await res.json();
1253
+ renderScanStatus(data);
1254
+ } catch (e) {
1255
+ console.error('Failed to load scan status:', e);
1256
+ }
1257
+ }
1258
+
1259
+ function renderScanStatus(data) {
1260
+ const el = (id) => document.getElementById(id);
1261
+ // TruffleHog status
1262
+ if (el('scan-thog-status')) {
1263
+ el('scan-thog-status').innerHTML = data.installed ? 'Installed' : 'Not installed<div style="font-size:11px;color:#a1a1aa;margin-top:4px">Run: <code style="background:#27272a;padding:2px 6px;border-radius:4px">brew install trufflehog</code></div>';
1264
+ el('scan-thog-status').style.color = data.installed ? '#4ade80' : '#f87171';
1265
+ }
1266
+ // Home page summary + findings from fresh scan data
1267
+ if (data.fresh) {
1268
+ const f = data.fresh;
1269
+ const verified = (f.findings || []).filter(x => x.verified).length;
1270
+ const total = (f.findings || []).length;
1271
+ const ago = f.summary && f.summary.scannedAt ? timeAgo(f.summary.scannedAt) : 'Just now';
1272
+ if (el('scan-last-time')) el('scan-last-time').textContent = ago;
1273
+ if (el('scan-verified')) el('scan-verified').textContent = verified;
1274
+ if (el('scan-total')) el('scan-total').textContent = total;
1275
+ if (el('home-scan-status')) {
1276
+ if (verified > 0) {
1277
+ el('home-scan-status').innerHTML = `<span style="color:#f87171">${verified} live secret${verified > 1 ? 's' : ''} found</span>`;
1278
+ } else if (total > 0) {
1279
+ el('home-scan-status').textContent = `${total} potential finding${total > 1 ? 's' : ''} (none verified)`;
1280
+ } else {
1281
+ el('home-scan-status').innerHTML = '<span style="color:#4ade80">No hardcoded secrets found</span>';
1282
+ }
1283
+ }
1284
+ if (el('home-scan-detail')) {
1285
+ el('home-scan-detail').textContent = `Last scan: ${ago}` + (f.summary ? ` · ${f.summary.targetsScanned || 0} directories scanned` : '');
1286
+ }
1287
+ // Reset Run Scan button
1288
+ if (el('scan-run-btn')) { el('scan-run-btn').disabled = false; el('scan-run-btn').innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan'; }
1289
+ // Populate findings table if not already set by a manual scan
1290
+ if (!scanData) {
1291
+ scanData = f;
1292
+ renderScanFindings();
1293
+ }
1294
+ } else if (data.scanning) {
1295
+ if (el('scan-last-time')) el('scan-last-time').textContent = 'Scanning...';
1296
+ if (el('scan-verified')) el('scan-verified').textContent = '-';
1297
+ if (el('scan-total')) el('scan-total').textContent = '-';
1298
+ if (el('home-scan-status')) el('home-scan-status').textContent = 'Scanning directories for secrets...';
1299
+ if (el('home-scan-detail')) el('home-scan-detail').textContent = '';
1300
+ // Update scan page too
1301
+ if (el('scan-findings-body')) el('scan-findings-body').innerHTML = '<div class="empty">Scanning... this may take a minute.</div>';
1302
+ if (el('scan-run-btn')) { el('scan-run-btn').disabled = true; el('scan-run-btn').innerHTML = '<svg style="width:14px;height:14px;animation:spin 1s linear infinite" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg> Scanning...'; }
1303
+ if (!document.getElementById('spin-style')) { const style = document.createElement('style'); style.id = 'spin-style'; style.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'; document.head.appendChild(style); }
1304
+ if (el('scan-solve-btn')) el('scan-solve-btn').style.display = 'none';
1305
+ if (el('scan-select-all-wrap')) el('scan-select-all-wrap').style.display = 'none';
1306
+ } else {
1307
+ if (el('scan-last-time')) el('scan-last-time').textContent = 'Never';
1308
+ if (el('scan-verified')) el('scan-verified').textContent = '-';
1309
+ if (el('scan-total')) el('scan-total').textContent = '-';
1310
+ if (el('home-scan-status')) el('home-scan-status').textContent = data.installed ? 'No scan data yet. Scan starting...' : 'TruffleHog not installed. Install with: brew install trufflehog';
1311
+ if (el('home-scan-detail')) el('home-scan-detail').textContent = '';
1312
+ }
1313
+ }
1314
+
1315
+ async function runScan() {
1316
+ const btn = document.getElementById('scan-run-btn');
1317
+ btn.disabled = true;
1318
+ btn.innerHTML = '<svg style="width:14px;height:14px;animation:spin 1s linear infinite" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg> Scanning...';
1319
+ if (!document.getElementById('spin-style')) {
1320
+ const style = document.createElement('style');
1321
+ style.id = 'spin-style';
1322
+ style.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
1323
+ document.head.appendChild(style);
1324
+ }
1325
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scanning... this may take a minute.</div>';
1326
+ try {
1327
+ await fetch('/api/scan', { method: 'POST' });
1328
+ // Scan runs in background on server — poll until done
1329
+ await pollScanResults();
1330
+ } catch (e) {
1331
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scan request sent. Polling for results...</div>';
1332
+ await pollScanResults();
1333
+ }
1334
+ }
1335
+
1336
+ async function pollScanResults() {
1337
+ const btn = document.getElementById('scan-run-btn');
1338
+ const maxPolls = 120; // 4 minutes max (2s intervals)
1339
+ for (let i = 0; i < maxPolls; i++) {
1340
+ await new Promise(r => setTimeout(r, 2000));
1341
+ try {
1342
+ const res = await fetch('/api/scan');
1343
+ const data = await res.json();
1344
+ if (data.scanning) continue; // still in progress
1345
+ // Scan complete — use fresh results if available
1346
+ if (data.fresh) {
1347
+ scanData = data.fresh;
1348
+ }
1349
+ // Update status cards
1350
+ renderScanStatus(data);
1351
+ if (scanData && scanData.targets) {
1352
+ document.getElementById('scan-targets-section').style.display = '';
1353
+ document.getElementById('scan-targets-list').innerHTML = scanData.targets.map(t =>
1354
+ `<div style="display:flex;align-items:center;gap:8px;padding:4px 0;font-size:13px;color:#a1a1aa"><span style="color:#4ade80">&#10003;</span> ${esc(t.label)}</div>`
1355
+ ).join('');
1356
+ }
1357
+ renderScanFindings();
1358
+ btn.disabled = false;
1359
+ btn.innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan';
1360
+ return;
1361
+ } catch {}
1362
+ }
1363
+ btn.disabled = false;
1364
+ btn.innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan';
1365
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scan timed out. Check server logs.</div>';
1366
+ }
1367
+
1368
+ function renderScanFindings() {
1369
+ const container = document.getElementById('scan-findings-body');
1370
+ const solveBtn = document.getElementById('scan-solve-btn');
1371
+ const selectAllWrap = document.getElementById('scan-select-all-wrap');
1372
+ if (!scanData || !scanData.findings || scanData.findings.length === 0) {
1373
+ container.innerHTML = '<div class="empty">No findings. Your codebase is clean!</div>';
1374
+ solveBtn.style.display = 'none';
1375
+ selectAllWrap.style.display = 'none';
1376
+ return;
1377
+ }
1378
+ const findings = scanData.findings;
1379
+ const hasVerified = findings.some(f => f.verified);
1380
+ solveBtn.style.display = hasVerified ? '' : 'none';
1381
+ selectAllWrap.style.display = hasVerified ? '' : 'none';
1382
+ container.innerHTML = `<table><thead><tr>
1383
+ <th style="width:32px"></th>
1384
+ <th>Detector</th>
1385
+ <th>File</th>
1386
+ <th>Line</th>
1387
+ <th>Status</th>
1388
+ <th>Secret (redacted)</th>
1389
+ <th>Scan Target</th>
1390
+ </tr></thead><tbody>` +
1391
+ findings.map((f, i) => {
1392
+ const verified = f.verified ? '<span class="badge badge-red">LIVE</span>' : '<span class="badge badge-gray">Inactive</span>';
1393
+ const checkDisabled = f.verified ? '' : 'disabled';
1394
+ return `<tr>
1395
+ <td><input type="checkbox" class="scan-check" data-index="${f.index}" ${checkDisabled}></td>
1396
+ <td style="font-weight:500">${esc(f.detectorName)}</td>
1397
+ <td class="cmd" title="${esc(f.file || '')}">${esc(f.file ? f.file.replace(/^\/Users\/[^/]+/, '~') : '-')}</td>
1398
+ <td style="color:#a1a1aa">${f.line || '-'}</td>
1399
+ <td>${verified}</td>
1400
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#a1a1aa">${esc(f.raw)}</td>
1401
+ <td style="font-size:12px;color:#a1a1aa">${esc(f.scanTarget || '')}</td>
1402
+ </tr>`;
1403
+ }).join('') + '</tbody></table>';
1404
+ }
1405
+
1406
+ function toggleScanSelectAll() {
1407
+ const checked = document.getElementById('scan-select-all')?.checked;
1408
+ document.querySelectorAll('.scan-check:not(:disabled)').forEach(cb => { cb.checked = checked; });
1409
+ }
1410
+
1411
+ async function runSolve() {
1412
+ const checks = document.querySelectorAll('.scan-check:checked');
1413
+ const indices = [...checks].map(cb => parseInt(cb.dataset.index));
1414
+ if (indices.length === 0) {
1415
+ alert('No findings selected. Check the boxes next to verified secrets to scrub.');
1416
+ return;
1417
+ }
1418
+ if (!confirm(`Replace ${indices.length} secret(s) in files with dummy values? This cannot be undone.`)) return;
1419
+ const btn = document.getElementById('scan-solve-btn');
1420
+ btn.disabled = true;
1421
+ btn.textContent = 'Scrubbing...';
1422
+ try {
1423
+ const res = await fetch('/api/solve', {
1424
+ method: 'POST',
1425
+ headers: { 'Content-Type': 'application/json' },
1426
+ body: JSON.stringify({ indices }),
1427
+ });
1428
+ const data = await res.json();
1429
+ if (data.error) {
1430
+ alert('Solve error: ' + data.error);
1431
+ return;
1432
+ }
1433
+ // Show results
1434
+ const resultsDiv = document.getElementById('scan-solve-results');
1435
+ const resultsBody = document.getElementById('scan-solve-body');
1436
+ resultsDiv.style.display = '';
1437
+ const succeeded = (data.results || []).filter(r => r.success);
1438
+ const failed = (data.results || []).filter(r => !r.success);
1439
+ resultsBody.innerHTML = `<table><thead><tr><th>Status</th><th>File</th><th>Detector</th><th>Original</th><th>Replaced With</th></tr></thead><tbody>` +
1440
+ (data.results || []).map(r => {
1441
+ const status = r.success ? '<span class="badge badge-green">Replaced</span>' : '<span class="badge badge-red">Failed</span>';
1442
+ return `<tr>
1443
+ <td>${status}</td>
1444
+ <td class="cmd">${esc((r.file || '').replace(/^\/Users\/[^/]+/, '~'))}</td>
1445
+ <td>${esc(r.detectorName || '')}</td>
1446
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#a1a1aa">${esc(r.original || r.error || '')}</td>
1447
+ <td style="font-family:ui-monospace,monospace;font-size:12px;color:#4ade80">${esc(r.dummy || '')}</td>
1448
+ </tr>`;
1449
+ }).join('') + '</tbody></table>';
1450
+ if (succeeded.length > 0) {
1451
+ resultsBody.innerHTML += `<div style="padding:16px;font-size:13px;color:#a1a1aa">
1452
+ ${succeeded.length} secret(s) replaced with dummy values. Remember to rotate the REAL secrets in their original services.
1453
+ </div>`;
1454
+ }
1455
+ // Clear scan data since files changed
1456
+ scanData = null;
1457
+ document.getElementById('scan-findings-body').innerHTML = '<div class="empty">Scan data cleared after scrub. Run a new scan to verify.</div>';
1458
+ document.getElementById('scan-solve-btn').style.display = 'none';
1459
+ document.getElementById('scan-select-all-wrap').style.display = 'none';
1460
+ // Refresh home summary
1461
+ loadScanStatus();
1462
+ } catch (e) {
1463
+ alert('Solve failed: ' + e.message);
1464
+ } finally {
1465
+ btn.disabled = false;
1466
+ btn.innerHTML = '<svg style="width:14px;height:14px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Scrub Selected';
1467
+ }
1468
+ }
1469
+
460
1470
  refresh();
461
1471
  setInterval(refresh, 10000);
462
1472
  </script>
463
1473
  </body>
464
1474
  </html>
1475
+
1476
+