@kernel.chat/kbot 3.40.0 → 3.42.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 (73) hide show
  1. package/README.md +5 -5
  2. package/dist/agent-teams.d.ts +1 -1
  3. package/dist/agent-teams.d.ts.map +1 -1
  4. package/dist/agent-teams.js +36 -3
  5. package/dist/agent-teams.js.map +1 -1
  6. package/dist/agents/specialists.d.ts.map +1 -1
  7. package/dist/agents/specialists.js +20 -0
  8. package/dist/agents/specialists.js.map +1 -1
  9. package/dist/channels/kbot-channel.js +8 -31
  10. package/dist/channels/kbot-channel.js.map +1 -1
  11. package/dist/cli.js +8 -8
  12. package/dist/digest.js +1 -1
  13. package/dist/digest.js.map +1 -1
  14. package/dist/email-service.d.ts.map +1 -1
  15. package/dist/email-service.js +1 -2
  16. package/dist/email-service.js.map +1 -1
  17. package/dist/episodic-memory.d.ts.map +1 -1
  18. package/dist/episodic-memory.js +14 -0
  19. package/dist/episodic-memory.js.map +1 -1
  20. package/dist/interactive-buttons.d.ts +90 -0
  21. package/dist/interactive-buttons.d.ts.map +1 -0
  22. package/dist/interactive-buttons.js +286 -0
  23. package/dist/interactive-buttons.js.map +1 -0
  24. package/dist/learned-router.d.ts.map +1 -1
  25. package/dist/learned-router.js +29 -0
  26. package/dist/learned-router.js.map +1 -1
  27. package/dist/memory-hotswap.d.ts +58 -0
  28. package/dist/memory-hotswap.d.ts.map +1 -0
  29. package/dist/memory-hotswap.js +288 -0
  30. package/dist/memory-hotswap.js.map +1 -0
  31. package/dist/personal-security.d.ts +142 -0
  32. package/dist/personal-security.d.ts.map +1 -0
  33. package/dist/personal-security.js +1151 -0
  34. package/dist/personal-security.js.map +1 -0
  35. package/dist/side-conversation.d.ts +58 -0
  36. package/dist/side-conversation.d.ts.map +1 -0
  37. package/dist/side-conversation.js +224 -0
  38. package/dist/side-conversation.js.map +1 -0
  39. package/dist/tools/email.d.ts.map +1 -1
  40. package/dist/tools/email.js +2 -3
  41. package/dist/tools/email.js.map +1 -1
  42. package/dist/tools/index.d.ts.map +1 -1
  43. package/dist/tools/index.js +7 -1
  44. package/dist/tools/index.js.map +1 -1
  45. package/dist/tools/lab-bio.d.ts +2 -0
  46. package/dist/tools/lab-bio.d.ts.map +1 -0
  47. package/dist/tools/lab-bio.js +1392 -0
  48. package/dist/tools/lab-bio.js.map +1 -0
  49. package/dist/tools/lab-chem.d.ts +2 -0
  50. package/dist/tools/lab-chem.d.ts.map +1 -0
  51. package/dist/tools/lab-chem.js +1257 -0
  52. package/dist/tools/lab-chem.js.map +1 -0
  53. package/dist/tools/lab-core.d.ts +2 -0
  54. package/dist/tools/lab-core.d.ts.map +1 -0
  55. package/dist/tools/lab-core.js +2452 -0
  56. package/dist/tools/lab-core.js.map +1 -0
  57. package/dist/tools/lab-data.d.ts +2 -0
  58. package/dist/tools/lab-data.d.ts.map +1 -0
  59. package/dist/tools/lab-data.js +2464 -0
  60. package/dist/tools/lab-data.js.map +1 -0
  61. package/dist/tools/lab-earth.d.ts +2 -0
  62. package/dist/tools/lab-earth.d.ts.map +1 -0
  63. package/dist/tools/lab-earth.js +1124 -0
  64. package/dist/tools/lab-earth.js.map +1 -0
  65. package/dist/tools/lab-math.d.ts +2 -0
  66. package/dist/tools/lab-math.d.ts.map +1 -0
  67. package/dist/tools/lab-math.js +3021 -0
  68. package/dist/tools/lab-math.js.map +1 -0
  69. package/dist/tools/lab-physics.d.ts +2 -0
  70. package/dist/tools/lab-physics.d.ts.map +1 -0
  71. package/dist/tools/lab-physics.js +2423 -0
  72. package/dist/tools/lab-physics.js.map +1 -0
  73. package/package.json +2 -3
@@ -0,0 +1,1151 @@
1
+ // kbot Personal Cybersecurity Suite
2
+ //
3
+ // kbot protects its creator. Not just kbot's memory — the entire machine
4
+ // and online presence.
5
+ //
6
+ // Capabilities:
7
+ // 1. Full security scan (secrets, SSH, permissions, ports, firewall, git, browser)
8
+ // 2. File change monitoring on sensitive directories
9
+ // 3. Breached email checking via Have I Been Pwned
10
+ // 4. Security report generation with scoring
11
+ // 5. Scheduled recurring scans with alerting
12
+ // 6. Focused scanners (secrets, SSH, ports, permissions)
13
+ //
14
+ // IMPORTANT: This tool NEVER reads or displays actual secret content.
15
+ // It only reports that a secret was found, what type, and where.
16
+ // Remediation is for the user to handle.
17
+ //
18
+ // Storage: ~/.kbot/security/
19
+ // Dependencies: Node built-ins only (fs, net, child_process, crypto, os, path)
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, appendFileSync, watch } from 'node:fs';
21
+ import { join, resolve, extname, relative } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+ import { execSync } from 'node:child_process';
24
+ import { createConnection } from 'node:net';
25
+ // ── Constants ──
26
+ const HOME = homedir();
27
+ const KBOT_DIR = join(HOME, '.kbot');
28
+ const SECURITY_DIR = join(KBOT_DIR, 'security');
29
+ const MONITOR_LOG = join(SECURITY_DIR, 'monitor-log.jsonl');
30
+ const SCAN_HISTORY = join(SECURITY_DIR, 'scan-history.json');
31
+ function ensureDir(dir) {
32
+ if (!existsSync(dir))
33
+ mkdirSync(dir, { recursive: true });
34
+ }
35
+ // ── Secret Patterns ──
36
+ // These detect common API key and credential formats without reading values.
37
+ const SECRET_PATTERNS = [
38
+ { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/ },
39
+ { name: 'AWS Secret Key', pattern: /(?:aws_secret|secret_access_key)\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}/ },
40
+ { name: 'GitHub Token (classic)', pattern: /ghp_[A-Za-z0-9]{36}/ },
41
+ { name: 'GitHub OAuth Token', pattern: /gho_[A-Za-z0-9]{36}/ },
42
+ { name: 'GitHub App Token', pattern: /ghs_[A-Za-z0-9]{36}/ },
43
+ { name: 'GitHub Fine-Grained Token', pattern: /github_pat_[A-Za-z0-9_]{82}/ },
44
+ { name: 'Stripe Secret Key', pattern: /sk_live_[A-Za-z0-9]{24,}/ },
45
+ { name: 'Stripe Restricted Key', pattern: /rk_live_[A-Za-z0-9]{24,}/ },
46
+ { name: 'Anthropic API Key', pattern: /sk-ant-[A-Za-z0-9_-]{40,}/ },
47
+ { name: 'OpenAI API Key', pattern: /sk-[A-Za-z0-9]{48}/ },
48
+ { name: 'Google API Key', pattern: /AIza[A-Za-z0-9_-]{35}/ },
49
+ { name: 'Slack Bot Token', pattern: /xoxb-[0-9]{10,}-[A-Za-z0-9]{24,}/ },
50
+ { name: 'Slack Webhook', pattern: /hooks\.slack\.com\/services\/T[A-Z0-9]{8,}\/B[A-Z0-9]{8,}\/[A-Za-z0-9]{24}/ },
51
+ { name: 'Discord Webhook', pattern: /discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/ },
52
+ { name: 'Discord Bot Token', pattern: /[MN][A-Za-z0-9]{23,28}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}/ },
53
+ { name: 'Private Key (PEM)', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
54
+ { name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ },
55
+ { name: 'PyPI Token', pattern: /pypi-[A-Za-z0-9_-]{50,}/ },
56
+ { name: 'Resend API Key', pattern: /re_[A-Za-z0-9]{32,}/ },
57
+ { name: 'SendGrid API Key', pattern: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/ },
58
+ { name: 'Twilio Account SID', pattern: /AC[a-f0-9]{32}/ },
59
+ { name: 'Twilio Auth Token', pattern: /(?:twilio_auth_token|TWILIO_AUTH)\s*[:=]\s*['"]?[a-f0-9]{32}/ },
60
+ { name: 'Supabase Service Key', pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}/ },
61
+ { name: 'Mailgun API Key', pattern: /key-[A-Za-z0-9]{32}/ },
62
+ { name: 'Heroku API Key', pattern: /(?:heroku.*key|HEROKU_API_KEY)\s*[:=]\s*['"]?[a-f0-9-]{36}/ },
63
+ { name: 'Generic High-Entropy Secret', pattern: /(?:secret|password|passwd|token|api_key|apikey|auth_token|access_token)\s*[:=]\s*['"][A-Za-z0-9/+=_-]{20,}['"]/ },
64
+ ];
65
+ const SKIP_DIRS = new Set([
66
+ 'node_modules', '.git', 'dist', 'build', '.next', '__pycache__',
67
+ 'target', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache',
68
+ 'coverage', '.nyc_output', '.cache', '.parcel-cache',
69
+ ]);
70
+ const SKIP_EXTENSIONS = new Set([
71
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.bmp', '.webp',
72
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
73
+ '.mp3', '.mp4', '.avi', '.mov', '.wav', '.flac',
74
+ '.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar',
75
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx',
76
+ '.lock', '.map', '.min.js', '.min.css',
77
+ '.pyc', '.pyo', '.class', '.o', '.so', '.dylib', '.dll',
78
+ ]);
79
+ /**
80
+ * Recursively scan a directory for leaked secrets (API keys, tokens, passwords).
81
+ * NEVER reads or displays actual secret values.
82
+ */
83
+ export function scanForSecrets(scanPath) {
84
+ const dir = resolve(scanPath || process.cwd());
85
+ const findings = [];
86
+ const maxDepth = 8;
87
+ function walk(dirPath, depth) {
88
+ if (depth > maxDepth)
89
+ return;
90
+ let entries;
91
+ try {
92
+ entries = readdirSync(dirPath);
93
+ }
94
+ catch {
95
+ return;
96
+ }
97
+ for (const entry of entries) {
98
+ if (SKIP_DIRS.has(entry))
99
+ continue;
100
+ // Skip .env files — we only look for ACCIDENTALLY committed secrets
101
+ if (entry.startsWith('.env'))
102
+ continue;
103
+ const fullPath = join(dirPath, entry);
104
+ let stat;
105
+ try {
106
+ stat = statSync(fullPath);
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ if (stat.isDirectory()) {
112
+ walk(fullPath, depth + 1);
113
+ continue;
114
+ }
115
+ if (!stat.isFile() || stat.size > 500_000)
116
+ continue;
117
+ const ext = extname(entry).toLowerCase();
118
+ if (SKIP_EXTENSIONS.has(ext))
119
+ continue;
120
+ let content;
121
+ try {
122
+ content = readFileSync(fullPath, 'utf-8');
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ const lines = content.split('\n');
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const line = lines[i];
130
+ // Skip comment-only lines that describe patterns rather than contain secrets
131
+ if (/^\s*(?:\/\/|#|\/\*|\*)\s/.test(line))
132
+ continue;
133
+ for (const { name, pattern } of SECRET_PATTERNS) {
134
+ const match = line.match(pattern);
135
+ if (match) {
136
+ const val = match[0];
137
+ const redacted = val.length > 12
138
+ ? val.slice(0, 8) + '***' + val.slice(-4)
139
+ : val.slice(0, 4) + '***';
140
+ findings.push({
141
+ file: relative(dir, fullPath),
142
+ line: i + 1,
143
+ type: name,
144
+ preview: redacted,
145
+ });
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ walk(dir, 0);
152
+ return findings;
153
+ }
154
+ /**
155
+ * Audit SSH configuration and keys.
156
+ * Checks for password-protected keys, authorized_keys cleanliness, and config issues.
157
+ */
158
+ export function checkSSHSecurity() {
159
+ const sshDir = join(HOME, '.ssh');
160
+ const findings = [];
161
+ const keysFound = [];
162
+ let authorizedKeysCount = 0;
163
+ const configIssues = [];
164
+ if (!existsSync(sshDir)) {
165
+ findings.push({
166
+ category: 'SSH',
167
+ title: 'No ~/.ssh directory found',
168
+ severity: 'low',
169
+ description: 'No SSH directory exists. This is fine if you do not use SSH.',
170
+ remediation: 'No action needed unless SSH is required.',
171
+ });
172
+ return { findings, keysFound, authorizedKeysCount, configIssues };
173
+ }
174
+ // Check directory permissions
175
+ try {
176
+ const sshStat = statSync(sshDir);
177
+ const mode = sshStat.mode & 0o777;
178
+ if (mode !== 0o700) {
179
+ findings.push({
180
+ category: 'SSH',
181
+ title: '~/.ssh directory has loose permissions',
182
+ severity: 'high',
183
+ description: `~/.ssh permissions are ${mode.toString(8)} (should be 700).`,
184
+ remediation: 'Run: chmod 700 ~/.ssh',
185
+ });
186
+ }
187
+ }
188
+ catch { /* cannot stat */ }
189
+ // Check each key file
190
+ let entries;
191
+ try {
192
+ entries = readdirSync(sshDir);
193
+ }
194
+ catch {
195
+ findings.push({
196
+ category: 'SSH',
197
+ title: 'Cannot read ~/.ssh directory',
198
+ severity: 'medium',
199
+ description: 'Unable to list files in ~/.ssh.',
200
+ remediation: 'Check permissions on ~/.ssh.',
201
+ });
202
+ return { findings, keysFound, authorizedKeysCount, configIssues };
203
+ }
204
+ for (const entry of entries) {
205
+ const fullPath = join(sshDir, entry);
206
+ let stat;
207
+ try {
208
+ stat = statSync(fullPath);
209
+ }
210
+ catch {
211
+ continue;
212
+ }
213
+ if (!stat.isFile())
214
+ continue;
215
+ // Check private key files
216
+ if (entry === 'id_rsa' || entry === 'id_ed25519' || entry === 'id_ecdsa' || entry === 'id_dsa' || entry.startsWith('id_') && !entry.endsWith('.pub')) {
217
+ const mode = stat.mode & 0o777;
218
+ if (mode !== 0o600 && mode !== 0o400) {
219
+ findings.push({
220
+ category: 'SSH',
221
+ title: `Private key ${entry} has loose permissions`,
222
+ severity: 'critical',
223
+ description: `${entry} permissions are ${mode.toString(8)} (should be 600 or 400).`,
224
+ remediation: `Run: chmod 600 ~/.ssh/${entry}`,
225
+ });
226
+ }
227
+ // Check if key has a passphrase (encrypted header indicates passphrase)
228
+ let hasPassphrase = null;
229
+ try {
230
+ const keyContent = readFileSync(fullPath, 'utf-8');
231
+ if (keyContent.includes('ENCRYPTED')) {
232
+ hasPassphrase = true;
233
+ }
234
+ else if (keyContent.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) {
235
+ // OpenSSH format — try ssh-keygen to check (non-destructive)
236
+ try {
237
+ execSync(`ssh-keygen -y -P "" -f "${fullPath}" 2>&1`, { timeout: 5000 });
238
+ // If the above succeeds with empty password, key has NO passphrase
239
+ hasPassphrase = false;
240
+ }
241
+ catch {
242
+ // If it fails, the key likely has a passphrase
243
+ hasPassphrase = true;
244
+ }
245
+ }
246
+ else if (keyContent.includes('-----BEGIN RSA PRIVATE KEY-----')) {
247
+ // Legacy PEM format — check for Proc-Type: 4,ENCRYPTED
248
+ hasPassphrase = keyContent.includes('Proc-Type') && keyContent.includes('ENCRYPTED');
249
+ }
250
+ }
251
+ catch {
252
+ hasPassphrase = null;
253
+ }
254
+ let keyType = 'unknown';
255
+ if (entry.includes('rsa'))
256
+ keyType = 'RSA';
257
+ else if (entry.includes('ed25519'))
258
+ keyType = 'Ed25519';
259
+ else if (entry.includes('ecdsa'))
260
+ keyType = 'ECDSA';
261
+ else if (entry.includes('dsa'))
262
+ keyType = 'DSA';
263
+ keysFound.push({ name: entry, type: keyType, hasPassphrase });
264
+ if (hasPassphrase === false) {
265
+ findings.push({
266
+ category: 'SSH',
267
+ title: `Private key ${entry} has no passphrase`,
268
+ severity: 'high',
269
+ description: `The private key ${entry} is not password-protected. If stolen, it can be used immediately.`,
270
+ remediation: `Add a passphrase: ssh-keygen -p -f ~/.ssh/${entry}`,
271
+ });
272
+ }
273
+ if (keyType === 'DSA') {
274
+ findings.push({
275
+ category: 'SSH',
276
+ title: `DSA key found (${entry})`,
277
+ severity: 'medium',
278
+ description: 'DSA keys are deprecated and considered weak. Use Ed25519 or ECDSA instead.',
279
+ remediation: 'Generate a new key: ssh-keygen -t ed25519 -C "your_email"',
280
+ });
281
+ }
282
+ if (keyType === 'RSA') {
283
+ // Check RSA key length
284
+ try {
285
+ const output = execSync(`ssh-keygen -l -f "${fullPath}" 2>/dev/null`, { timeout: 5000 }).toString();
286
+ const bits = parseInt(output.split(' ')[0], 10);
287
+ if (bits < 2048) {
288
+ findings.push({
289
+ category: 'SSH',
290
+ title: `RSA key ${entry} is only ${bits} bits`,
291
+ severity: 'high',
292
+ description: `RSA key should be at least 2048 bits, preferably 4096. Found ${bits} bits.`,
293
+ remediation: 'Generate a stronger key: ssh-keygen -t rsa -b 4096 or ssh-keygen -t ed25519',
294
+ });
295
+ }
296
+ }
297
+ catch { /* cannot check size */ }
298
+ }
299
+ }
300
+ // Check authorized_keys
301
+ if (entry === 'authorized_keys') {
302
+ try {
303
+ const content = readFileSync(fullPath, 'utf-8');
304
+ const keys = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'));
305
+ authorizedKeysCount = keys.length;
306
+ if (keys.length > 10) {
307
+ findings.push({
308
+ category: 'SSH',
309
+ title: 'Large authorized_keys file',
310
+ severity: 'medium',
311
+ description: `authorized_keys contains ${keys.length} entries. Review for stale or unknown keys.`,
312
+ remediation: 'Review ~/.ssh/authorized_keys and remove keys you do not recognize.',
313
+ });
314
+ }
315
+ // Check for command restrictions
316
+ const unrestrictedKeys = keys.filter(k => !k.includes('command=') && !k.includes('restrict'));
317
+ if (unrestrictedKeys.length > 5) {
318
+ findings.push({
319
+ category: 'SSH',
320
+ title: 'Many unrestricted authorized keys',
321
+ severity: 'low',
322
+ description: `${unrestrictedKeys.length} authorized keys have no command restrictions.`,
323
+ remediation: 'Consider adding command= or restrict options for service keys.',
324
+ });
325
+ }
326
+ }
327
+ catch { /* cannot read */ }
328
+ const mode = stat.mode & 0o777;
329
+ if (mode !== 0o600 && mode !== 0o644) {
330
+ findings.push({
331
+ category: 'SSH',
332
+ title: 'authorized_keys has unusual permissions',
333
+ severity: 'medium',
334
+ description: `authorized_keys permissions are ${mode.toString(8)} (should be 600 or 644).`,
335
+ remediation: 'Run: chmod 600 ~/.ssh/authorized_keys',
336
+ });
337
+ }
338
+ }
339
+ }
340
+ // Check ssh_config for risky settings
341
+ const sshConfig = join(sshDir, 'config');
342
+ if (existsSync(sshConfig)) {
343
+ try {
344
+ const content = readFileSync(sshConfig, 'utf-8');
345
+ if (/StrictHostKeyChecking\s+no/i.test(content)) {
346
+ configIssues.push('StrictHostKeyChecking is disabled — vulnerable to MITM attacks');
347
+ findings.push({
348
+ category: 'SSH',
349
+ title: 'StrictHostKeyChecking disabled in SSH config',
350
+ severity: 'high',
351
+ description: 'SSH config disables host key verification, making you vulnerable to man-in-the-middle attacks.',
352
+ remediation: 'Remove or change "StrictHostKeyChecking no" in ~/.ssh/config.',
353
+ });
354
+ }
355
+ if (/ForwardAgent\s+yes/i.test(content)) {
356
+ configIssues.push('Agent forwarding is enabled globally — risk of key theft on compromised hosts');
357
+ findings.push({
358
+ category: 'SSH',
359
+ title: 'SSH Agent Forwarding enabled globally',
360
+ severity: 'medium',
361
+ description: 'Global agent forwarding exposes your SSH keys to any host you connect to.',
362
+ remediation: 'Only enable ForwardAgent for specific trusted hosts, not globally.',
363
+ });
364
+ }
365
+ if (/PasswordAuthentication\s+yes/i.test(content)) {
366
+ configIssues.push('Password authentication is enabled — prefer key-based auth');
367
+ }
368
+ }
369
+ catch { /* cannot read config */ }
370
+ }
371
+ return { findings, keysFound, authorizedKeysCount, configIssues };
372
+ }
373
+ /**
374
+ * Scan common ports on localhost to check for exposed services.
375
+ * Uses TCP connect probes via the net module.
376
+ */
377
+ export async function checkPortExposure() {
378
+ const PORTS = [
379
+ { port: 22, service: 'SSH', risky: false },
380
+ { port: 80, service: 'HTTP', risky: false },
381
+ { port: 443, service: 'HTTPS', risky: false },
382
+ { port: 3000, service: 'Dev Server (Node)', risky: false },
383
+ { port: 3306, service: 'MySQL', risky: true },
384
+ { port: 5432, service: 'PostgreSQL', risky: true },
385
+ { port: 5900, service: 'VNC', risky: true },
386
+ { port: 6379, service: 'Redis', risky: true },
387
+ { port: 8080, service: 'HTTP Alt / Proxy', risky: false },
388
+ { port: 8443, service: 'HTTPS Alt', risky: false },
389
+ { port: 8888, service: 'Jupyter', risky: true },
390
+ { port: 9090, service: 'Prometheus', risky: true },
391
+ { port: 11434, service: 'Ollama', risky: false },
392
+ { port: 18789, service: 'kbot Local', risky: false },
393
+ { port: 27017, service: 'MongoDB', risky: true },
394
+ ];
395
+ const results = [];
396
+ function probePort(port) {
397
+ return new Promise((resolveProbe) => {
398
+ const socket = createConnection({ host: '127.0.0.1', port }, () => {
399
+ socket.destroy();
400
+ resolveProbe(true);
401
+ });
402
+ socket.setTimeout(2000);
403
+ socket.on('timeout', () => {
404
+ socket.destroy();
405
+ resolveProbe(false);
406
+ });
407
+ socket.on('error', () => {
408
+ socket.destroy();
409
+ resolveProbe(false);
410
+ });
411
+ });
412
+ }
413
+ await Promise.all(PORTS.map(async ({ port, service }) => {
414
+ const open = await probePort(port);
415
+ results.push({ port, service, open });
416
+ }));
417
+ results.sort((a, b) => a.port - b.port);
418
+ const findings = [];
419
+ const openPorts = results.filter(r => r.open);
420
+ for (const r of openPorts) {
421
+ const portInfo = PORTS.find(p => p.port === r.port);
422
+ if (portInfo?.risky) {
423
+ findings.push({
424
+ category: 'Network',
425
+ title: `${r.service} (port ${r.port}) is open on localhost`,
426
+ severity: r.port === 6379 || r.port === 27017 ? 'high' : 'medium',
427
+ description: `${r.service} is listening on port ${r.port}. If bound to 0.0.0.0, it may be accessible from the network.`,
428
+ remediation: `Ensure ${r.service} is bound to 127.0.0.1 only, or block port ${r.port} in the firewall.`,
429
+ });
430
+ }
431
+ }
432
+ return { ports: results, findings };
433
+ }
434
+ /**
435
+ * Verify that sensitive files and directories are not world-readable or world-writable.
436
+ */
437
+ export function checkFilePermissions(paths) {
438
+ const defaultPaths = [
439
+ join(HOME, '.ssh'),
440
+ join(HOME, '.kbot'),
441
+ join(HOME, '.env'),
442
+ join(HOME, '.bashrc'),
443
+ join(HOME, '.zshrc'),
444
+ join(HOME, '.bash_history'),
445
+ join(HOME, '.zsh_history'),
446
+ join(HOME, '.gitconfig'),
447
+ join(HOME, '.npmrc'),
448
+ join(HOME, '.aws', 'credentials'),
449
+ join(HOME, '.config', 'gcloud'),
450
+ join(HOME, '.docker', 'config.json'),
451
+ join(HOME, '.kube', 'config'),
452
+ ];
453
+ const checkPaths = paths || defaultPaths;
454
+ const results = [];
455
+ for (const p of checkPaths) {
456
+ if (!existsSync(p)) {
457
+ results.push({ path: p, exists: false, mode: null, isWorldReadable: false, isWorldWritable: false, finding: null });
458
+ continue;
459
+ }
460
+ let stat;
461
+ try {
462
+ stat = statSync(p);
463
+ }
464
+ catch {
465
+ results.push({ path: p, exists: true, mode: null, isWorldReadable: false, isWorldWritable: false, finding: null });
466
+ continue;
467
+ }
468
+ const mode = stat.mode & 0o777;
469
+ const modeStr = mode.toString(8);
470
+ const isWorldReadable = (mode & 0o004) !== 0;
471
+ const isWorldWritable = (mode & 0o002) !== 0;
472
+ let finding = null;
473
+ if (isWorldWritable) {
474
+ finding = {
475
+ category: 'Permissions',
476
+ title: `${relative(HOME, p) || p} is world-writable`,
477
+ severity: 'critical',
478
+ description: `${p} has permissions ${modeStr} — any user on this machine can modify it.`,
479
+ remediation: `Run: chmod o-w "${p}"`,
480
+ location: p,
481
+ };
482
+ }
483
+ else if (isWorldReadable) {
484
+ // Some files being world-readable is more concerning than others
485
+ const sensitivePaths = ['.ssh', '.kbot', '.env', '.aws', '.npmrc', '.docker', '.kube'];
486
+ const isSensitive = sensitivePaths.some(s => p.includes(s));
487
+ if (isSensitive) {
488
+ finding = {
489
+ category: 'Permissions',
490
+ title: `${relative(HOME, p) || p} is world-readable`,
491
+ severity: 'high',
492
+ description: `${p} has permissions ${modeStr} — any user on this machine can read it.`,
493
+ remediation: `Run: chmod o-r "${p}" (or chmod 600 for files, 700 for directories)`,
494
+ location: p,
495
+ };
496
+ }
497
+ }
498
+ results.push({ path: p, exists: true, mode: modeStr, isWorldReadable, isWorldWritable, finding });
499
+ }
500
+ return results;
501
+ }
502
+ // ── 5. Firewall Check ──
503
+ function checkFirewall() {
504
+ const findings = [];
505
+ if (process.platform === 'darwin') {
506
+ try {
507
+ const result = execSync('sudo pfctl -s info 2>/dev/null || pfctl -s info 2>&1', { timeout: 5000 }).toString();
508
+ const enabled = /Status:\s*Enabled/i.test(result);
509
+ if (!enabled) {
510
+ findings.push({
511
+ category: 'Firewall',
512
+ title: 'macOS packet filter (pf) is disabled',
513
+ severity: 'medium',
514
+ description: 'The macOS packet filter firewall is not enabled.',
515
+ remediation: 'Enable via System Settings > Network > Firewall, or run: sudo pfctl -e',
516
+ });
517
+ }
518
+ }
519
+ catch {
520
+ // Try the application firewall check instead
521
+ try {
522
+ const result = execSync('/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>/dev/null', { timeout: 5000 }).toString();
523
+ if (result.includes('disabled')) {
524
+ findings.push({
525
+ category: 'Firewall',
526
+ title: 'macOS Application Firewall is disabled',
527
+ severity: 'medium',
528
+ description: 'The macOS Application Firewall is not enabled.',
529
+ remediation: 'Enable via System Settings > Network > Firewall.',
530
+ });
531
+ }
532
+ }
533
+ catch {
534
+ findings.push({
535
+ category: 'Firewall',
536
+ title: 'Could not determine firewall status',
537
+ severity: 'low',
538
+ description: 'Unable to check firewall status. This may require elevated privileges.',
539
+ remediation: 'Run this scan with sudo, or manually check System Settings > Network > Firewall.',
540
+ });
541
+ }
542
+ }
543
+ }
544
+ else if (process.platform === 'linux') {
545
+ try {
546
+ const result = execSync('ufw status 2>/dev/null || iptables -L -n 2>/dev/null | head -5', { timeout: 5000 }).toString();
547
+ if (result.includes('inactive') || result.includes('Status: inactive')) {
548
+ findings.push({
549
+ category: 'Firewall',
550
+ title: 'Linux firewall (ufw) is inactive',
551
+ severity: 'medium',
552
+ description: 'The ufw firewall is not enabled.',
553
+ remediation: 'Enable with: sudo ufw enable',
554
+ });
555
+ }
556
+ }
557
+ catch {
558
+ findings.push({
559
+ category: 'Firewall',
560
+ title: 'Could not determine firewall status',
561
+ severity: 'low',
562
+ description: 'Unable to check firewall status.',
563
+ remediation: 'Check manually with: sudo ufw status or sudo iptables -L',
564
+ });
565
+ }
566
+ }
567
+ return findings;
568
+ }
569
+ // ── 6. Git Config Check ──
570
+ function checkGitConfig() {
571
+ const findings = [];
572
+ try {
573
+ const email = execSync('git config --global user.email 2>/dev/null', { timeout: 3000 }).toString().trim();
574
+ const name = execSync('git config --global user.name 2>/dev/null', { timeout: 3000 }).toString().trim();
575
+ if (email) {
576
+ // Check if email looks like a personal email that shouldn't be public
577
+ const personalDomains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com', 'protonmail.com', 'proton.me'];
578
+ const domain = email.split('@')[1]?.toLowerCase();
579
+ if (personalDomains.includes(domain || '')) {
580
+ findings.push({
581
+ category: 'Git',
582
+ title: 'Personal email in git config',
583
+ severity: 'low',
584
+ description: `Git is configured with "${email}" which will appear in public commit history.`,
585
+ remediation: 'Use a noreply email (GitHub: Settings > Emails > Keep my email private), or configure per-repo.',
586
+ });
587
+ }
588
+ }
589
+ if (!name && !email) {
590
+ findings.push({
591
+ category: 'Git',
592
+ title: 'Git user not configured',
593
+ severity: 'low',
594
+ description: 'No global git user.name or user.email is set.',
595
+ remediation: 'Run: git config --global user.name "Name" && git config --global user.email "email"',
596
+ });
597
+ }
598
+ // Check for credential helpers that might store plaintext
599
+ try {
600
+ const credHelper = execSync('git config --global credential.helper 2>/dev/null', { timeout: 3000 }).toString().trim();
601
+ if (credHelper === 'store') {
602
+ findings.push({
603
+ category: 'Git',
604
+ title: 'Git credentials stored in plaintext',
605
+ severity: 'high',
606
+ description: 'Git credential.helper is set to "store" which saves passwords in plaintext at ~/.git-credentials.',
607
+ remediation: 'Use a secure credential helper: git config --global credential.helper osxkeychain (macOS) or cache (Linux).',
608
+ });
609
+ }
610
+ }
611
+ catch { /* no credential helper */ }
612
+ // Check for .git-credentials file
613
+ const gitCredentials = join(HOME, '.git-credentials');
614
+ if (existsSync(gitCredentials)) {
615
+ findings.push({
616
+ category: 'Git',
617
+ title: 'Plaintext git credentials file exists',
618
+ severity: 'high',
619
+ description: '~/.git-credentials contains plaintext credentials.',
620
+ remediation: 'Switch to osxkeychain (macOS) or libsecret (Linux), then delete ~/.git-credentials.',
621
+ });
622
+ }
623
+ }
624
+ catch {
625
+ // git not installed
626
+ }
627
+ return findings;
628
+ }
629
+ // ── 7. Browser Data Check ──
630
+ function checkBrowserData() {
631
+ const findings = [];
632
+ const browserPaths = process.platform === 'darwin'
633
+ ? [
634
+ { name: 'Chrome', path: join(HOME, 'Library/Application Support/Google/Chrome') },
635
+ { name: 'Firefox', path: join(HOME, 'Library/Application Support/Firefox/Profiles') },
636
+ { name: 'Safari', path: join(HOME, 'Library/Safari') },
637
+ { name: 'Brave', path: join(HOME, 'Library/Application Support/BraveSoftware/Brave-Browser') },
638
+ { name: 'Edge', path: join(HOME, 'Library/Application Support/Microsoft Edge') },
639
+ { name: 'Arc', path: join(HOME, 'Library/Application Support/Arc') },
640
+ ]
641
+ : [
642
+ { name: 'Chrome', path: join(HOME, '.config/google-chrome') },
643
+ { name: 'Firefox', path: join(HOME, '.mozilla/firefox') },
644
+ { name: 'Brave', path: join(HOME, '.config/BraveSoftware/Brave-Browser') },
645
+ { name: 'Edge', path: join(HOME, '.config/microsoft-edge') },
646
+ ];
647
+ for (const { name, path: browserPath } of browserPaths) {
648
+ if (!existsSync(browserPath))
649
+ continue;
650
+ // Check for Login Data (Chromium browsers store passwords in SQLite)
651
+ const loginDataPaths = [
652
+ join(browserPath, 'Default', 'Login Data'),
653
+ join(browserPath, 'Profile 1', 'Login Data'),
654
+ ];
655
+ for (const loginPath of loginDataPaths) {
656
+ if (existsSync(loginPath)) {
657
+ findings.push({
658
+ category: 'Browser',
659
+ title: `${name} password database exists`,
660
+ severity: 'low',
661
+ description: `${name} stores saved passwords locally at ${relative(HOME, loginPath)}. These are encrypted with OS keychain but accessible to local processes.`,
662
+ remediation: 'Consider using a dedicated password manager (1Password, Bitwarden) instead of browser-saved passwords.',
663
+ });
664
+ break;
665
+ }
666
+ }
667
+ }
668
+ return findings;
669
+ }
670
+ // ── 8. npm Global Packages Check ──
671
+ function checkNpmGlobalPackages() {
672
+ const findings = [];
673
+ try {
674
+ const result = execSync('npm outdated -g --json 2>/dev/null || echo "{}"', { timeout: 30_000, maxBuffer: 2_000_000 }).toString();
675
+ const outdated = JSON.parse(result);
676
+ const outdatedPkgs = Object.keys(outdated);
677
+ if (outdatedPkgs.length > 0) {
678
+ const majorUpdates = outdatedPkgs.filter(pkg => {
679
+ const info = outdated[pkg];
680
+ const current = info.current?.split('.')[0];
681
+ const latest = info.latest?.split('.')[0];
682
+ return current && latest && current !== latest;
683
+ });
684
+ if (majorUpdates.length > 0) {
685
+ findings.push({
686
+ category: 'Dependencies',
687
+ title: `${majorUpdates.length} globally installed npm package(s) have major version updates`,
688
+ severity: 'medium',
689
+ description: `Packages with major updates: ${majorUpdates.slice(0, 5).join(', ')}${majorUpdates.length > 5 ? ` and ${majorUpdates.length - 5} more` : ''}.`,
690
+ remediation: 'Run: npm update -g (or npm install -g <package>@latest for specific packages).',
691
+ });
692
+ }
693
+ if (outdatedPkgs.length > 5) {
694
+ findings.push({
695
+ category: 'Dependencies',
696
+ title: `${outdatedPkgs.length} outdated global npm packages`,
697
+ severity: 'low',
698
+ description: 'Outdated packages may contain known vulnerabilities.',
699
+ remediation: 'Run: npm update -g',
700
+ });
701
+ }
702
+ }
703
+ }
704
+ catch {
705
+ // npm not available or errored — skip
706
+ }
707
+ return findings;
708
+ }
709
+ /**
710
+ * Full personal security audit.
711
+ * Checks: secrets, SSH, permissions, ports, firewall, git, browser, npm globals.
712
+ */
713
+ export async function runSecurityScan(options) {
714
+ const allFindings = [];
715
+ const goodPractices = [];
716
+ // 1. Secret scan
717
+ const secrets = scanForSecrets(options?.secretsScanPath);
718
+ for (const s of secrets) {
719
+ allFindings.push({
720
+ category: 'Secrets',
721
+ title: `${s.type} found in source`,
722
+ severity: 'critical',
723
+ description: `Found ${s.type} at ${s.file}:${s.line} (preview: ${s.preview}).`,
724
+ remediation: 'Remove the secret from source code, rotate the key, and add the file to .gitignore.',
725
+ location: `${s.file}:${s.line}`,
726
+ });
727
+ }
728
+ if (secrets.length === 0) {
729
+ goodPractices.push('No leaked secrets detected in source code.');
730
+ }
731
+ // 2. SSH security
732
+ const sshAudit = checkSSHSecurity();
733
+ allFindings.push(...sshAudit.findings);
734
+ if (sshAudit.findings.length === 0 && sshAudit.keysFound.length > 0) {
735
+ goodPractices.push('SSH keys are properly configured and secured.');
736
+ }
737
+ const protectedKeys = sshAudit.keysFound.filter(k => k.hasPassphrase === true);
738
+ if (protectedKeys.length > 0) {
739
+ goodPractices.push(`${protectedKeys.length} SSH key(s) are passphrase-protected.`);
740
+ }
741
+ // 3. File permissions
742
+ const permChecks = checkFilePermissions();
743
+ for (const pc of permChecks) {
744
+ if (pc.finding)
745
+ allFindings.push(pc.finding);
746
+ }
747
+ const securedFiles = permChecks.filter(p => p.exists && !p.isWorldReadable && !p.isWorldWritable);
748
+ if (securedFiles.length > 0) {
749
+ goodPractices.push(`${securedFiles.length} sensitive files/dirs have proper permissions.`);
750
+ }
751
+ // 4. Open ports
752
+ if (!options?.skipPorts) {
753
+ const portScan = await checkPortExposure();
754
+ allFindings.push(...portScan.findings);
755
+ const openPorts = portScan.ports.filter(p => p.open);
756
+ if (portScan.findings.length === 0 && openPorts.length > 0) {
757
+ goodPractices.push('Open ports are non-risky services only.');
758
+ }
759
+ if (openPorts.length === 0) {
760
+ goodPractices.push('No common ports are open on localhost.');
761
+ }
762
+ }
763
+ // 5. Firewall
764
+ const firewallFindings = checkFirewall();
765
+ allFindings.push(...firewallFindings);
766
+ if (firewallFindings.length === 0) {
767
+ goodPractices.push('Firewall is enabled.');
768
+ }
769
+ // 6. Git config
770
+ const gitFindings = checkGitConfig();
771
+ allFindings.push(...gitFindings);
772
+ if (gitFindings.length === 0) {
773
+ goodPractices.push('Git configuration is clean (no plaintext credential storage).');
774
+ }
775
+ // 7. Browser data
776
+ if (!options?.skipBrowser) {
777
+ const browserFindings = checkBrowserData();
778
+ allFindings.push(...browserFindings);
779
+ }
780
+ // 8. npm globals
781
+ if (!options?.skipNpm) {
782
+ const npmFindings = checkNpmGlobalPackages();
783
+ allFindings.push(...npmFindings);
784
+ if (npmFindings.length === 0) {
785
+ goodPractices.push('Global npm packages are up to date.');
786
+ }
787
+ }
788
+ // Compute score
789
+ const criticalCount = allFindings.filter(f => f.severity === 'critical').length;
790
+ const highCount = allFindings.filter(f => f.severity === 'high').length;
791
+ const mediumCount = allFindings.filter(f => f.severity === 'medium').length;
792
+ const lowCount = allFindings.filter(f => f.severity === 'low').length;
793
+ // Score: start at 100, deduct by severity
794
+ let score = 100;
795
+ score -= criticalCount * 15;
796
+ score -= highCount * 8;
797
+ score -= mediumCount * 4;
798
+ score -= lowCount * 1;
799
+ score = Math.max(0, Math.min(100, score));
800
+ const summaryParts = [];
801
+ if (criticalCount > 0)
802
+ summaryParts.push(`${criticalCount} critical`);
803
+ if (highCount > 0)
804
+ summaryParts.push(`${highCount} high`);
805
+ if (mediumCount > 0)
806
+ summaryParts.push(`${mediumCount} medium`);
807
+ if (lowCount > 0)
808
+ summaryParts.push(`${lowCount} low`);
809
+ const summary = summaryParts.length > 0
810
+ ? `Found ${allFindings.length} issue(s): ${summaryParts.join(', ')}. Score: ${score}/100.`
811
+ : `No issues found. Score: ${score}/100. Your machine is well-secured.`;
812
+ return {
813
+ timestamp: new Date().toISOString(),
814
+ score,
815
+ findings: allFindings,
816
+ criticalCount,
817
+ highCount,
818
+ mediumCount,
819
+ lowCount,
820
+ goodPractices,
821
+ summary,
822
+ };
823
+ }
824
+ // ── monitorFileChanges ──
825
+ const activeWatchers = [];
826
+ /**
827
+ * Watch sensitive directories for unexpected changes.
828
+ * Logs all change events to ~/.kbot/security/monitor-log.jsonl.
829
+ * Returns a dispose function to stop watching.
830
+ */
831
+ export function monitorFileChanges(paths) {
832
+ const defaultPaths = [
833
+ join(HOME, '.ssh'),
834
+ join(HOME, '.kbot'),
835
+ join(HOME, '.env'),
836
+ join(HOME, '.gitconfig'),
837
+ ];
838
+ const watchPaths = paths || defaultPaths;
839
+ ensureDir(SECURITY_DIR);
840
+ for (const watchPath of watchPaths) {
841
+ if (!existsSync(watchPath))
842
+ continue;
843
+ try {
844
+ const watcher = watch(watchPath, { recursive: true }, (eventType, filename) => {
845
+ const event = {
846
+ timestamp: new Date().toISOString(),
847
+ path: watchPath,
848
+ eventType,
849
+ filename: filename || null,
850
+ };
851
+ try {
852
+ appendFileSync(MONITOR_LOG, JSON.stringify(event) + '\n');
853
+ }
854
+ catch {
855
+ // Cannot write log — fail silently
856
+ }
857
+ });
858
+ activeWatchers.push(watcher);
859
+ }
860
+ catch {
861
+ // Cannot watch this path — skip
862
+ }
863
+ }
864
+ return {
865
+ dispose() {
866
+ for (const w of activeWatchers) {
867
+ try {
868
+ w.close();
869
+ }
870
+ catch { /* ignore */ }
871
+ }
872
+ activeWatchers.length = 0;
873
+ },
874
+ };
875
+ }
876
+ // ── checkBreachedEmails ──
877
+ /**
878
+ * Check if email addresses appear in known data breaches using the Have I Been Pwned API.
879
+ * Uses the free, public, unauthenticated breach search endpoint.
880
+ */
881
+ export async function checkBreachedEmails(emails) {
882
+ const results = [];
883
+ for (const email of emails) {
884
+ const trimmed = email.trim().toLowerCase();
885
+ if (!trimmed)
886
+ continue;
887
+ try {
888
+ // HIBP API v3 — breachedaccount endpoint (requires User-Agent)
889
+ const url = `https://haveibeenpwned.com/api/v3/breachedaccount/${encodeURIComponent(trimmed)}?truncateResponse=false`;
890
+ const res = await fetch(url, {
891
+ headers: {
892
+ 'User-Agent': 'kbot-security-scanner',
893
+ 'Accept': 'application/json',
894
+ },
895
+ signal: AbortSignal.timeout(10_000),
896
+ });
897
+ if (res.status === 404) {
898
+ // Not found = not breached
899
+ results.push({ email: trimmed, breached: false, breachCount: 0, breaches: [] });
900
+ }
901
+ else if (res.status === 200) {
902
+ const breaches = await res.json();
903
+ results.push({
904
+ email: trimmed,
905
+ breached: true,
906
+ breachCount: breaches.length,
907
+ breaches: breaches.map(b => ({
908
+ name: b.Name,
909
+ domain: b.Domain,
910
+ breachDate: b.BreachDate,
911
+ dataClasses: b.DataClasses,
912
+ })),
913
+ });
914
+ }
915
+ else if (res.status === 429) {
916
+ // Rate limited — HIBP has strict rate limits on the free tier
917
+ results.push({
918
+ email: trimmed,
919
+ breached: false,
920
+ breachCount: 0,
921
+ breaches: [{ name: 'RATE_LIMITED', domain: '', breachDate: '', dataClasses: ['Rate limited by HIBP — wait 6 seconds between requests or use an API key'] }],
922
+ });
923
+ }
924
+ else if (res.status === 401) {
925
+ // API key required for this endpoint
926
+ results.push({
927
+ email: trimmed,
928
+ breached: false,
929
+ breachCount: 0,
930
+ breaches: [{ name: 'API_KEY_REQUIRED', domain: '', breachDate: '', dataClasses: ['HIBP now requires an API key ($3.50/mo) for email breach lookups. See haveibeenpwned.com/API/Key'] }],
931
+ });
932
+ }
933
+ }
934
+ catch {
935
+ results.push({
936
+ email: trimmed,
937
+ breached: false,
938
+ breachCount: 0,
939
+ breaches: [{ name: 'ERROR', domain: '', breachDate: '', dataClasses: ['Failed to reach HIBP API'] }],
940
+ });
941
+ }
942
+ // HIBP rate limit: 1 request per 1.5 seconds (free tier)
943
+ if (emails.indexOf(email) < emails.length - 1) {
944
+ await new Promise(r => setTimeout(r, 1600));
945
+ }
946
+ }
947
+ return results;
948
+ }
949
+ // ── generateSecurityReport ──
950
+ /**
951
+ * Run a full scan and format it into a human-readable report.
952
+ */
953
+ export async function generateSecurityReport(options) {
954
+ const report = await runSecurityScan(options);
955
+ const lines = [];
956
+ // Header
957
+ lines.push('=====================================');
958
+ lines.push(' KBOT PERSONAL SECURITY REPORT');
959
+ lines.push('=====================================');
960
+ lines.push(` Date: ${new Date(report.timestamp).toLocaleString()}`);
961
+ lines.push('');
962
+ // Score
963
+ const scoreBar = generateScoreBar(report.score);
964
+ lines.push(` Overall Score: ${report.score}/100 ${scoreBar}`);
965
+ lines.push('');
966
+ if (report.score >= 90) {
967
+ lines.push(' Status: EXCELLENT — Your machine is well-secured.');
968
+ }
969
+ else if (report.score >= 70) {
970
+ lines.push(' Status: GOOD — A few items need attention.');
971
+ }
972
+ else if (report.score >= 50) {
973
+ lines.push(' Status: FAIR — Several security issues found.');
974
+ }
975
+ else {
976
+ lines.push(' Status: POOR — Critical issues require immediate action.');
977
+ }
978
+ lines.push('');
979
+ // Critical findings
980
+ const criticals = report.findings.filter(f => f.severity === 'critical');
981
+ if (criticals.length > 0) {
982
+ lines.push('-------------------------------------');
983
+ lines.push(' CRITICAL FINDINGS (Must Fix)');
984
+ lines.push('-------------------------------------');
985
+ for (const f of criticals) {
986
+ lines.push('');
987
+ lines.push(` [CRITICAL] ${f.title}`);
988
+ lines.push(` Category: ${f.category}`);
989
+ lines.push(` ${f.description}`);
990
+ lines.push(` Fix: ${f.remediation}`);
991
+ if (f.location)
992
+ lines.push(` Location: ${f.location}`);
993
+ }
994
+ lines.push('');
995
+ }
996
+ // High findings
997
+ const highs = report.findings.filter(f => f.severity === 'high');
998
+ if (highs.length > 0) {
999
+ lines.push('-------------------------------------');
1000
+ lines.push(' HIGH SEVERITY FINDINGS');
1001
+ lines.push('-------------------------------------');
1002
+ for (const f of highs) {
1003
+ lines.push('');
1004
+ lines.push(` [HIGH] ${f.title}`);
1005
+ lines.push(` Category: ${f.category}`);
1006
+ lines.push(` ${f.description}`);
1007
+ lines.push(` Fix: ${f.remediation}`);
1008
+ if (f.location)
1009
+ lines.push(` Location: ${f.location}`);
1010
+ }
1011
+ lines.push('');
1012
+ }
1013
+ // Medium & Low findings
1014
+ const mediums = report.findings.filter(f => f.severity === 'medium');
1015
+ const lows = report.findings.filter(f => f.severity === 'low');
1016
+ if (mediums.length > 0 || lows.length > 0) {
1017
+ lines.push('-------------------------------------');
1018
+ lines.push(' RECOMMENDATIONS');
1019
+ lines.push('-------------------------------------');
1020
+ for (const f of [...mediums, ...lows]) {
1021
+ lines.push(` [${f.severity.toUpperCase()}] ${f.title} — ${f.remediation}`);
1022
+ }
1023
+ lines.push('');
1024
+ }
1025
+ // Good practices
1026
+ if (report.goodPractices.length > 0) {
1027
+ lines.push('-------------------------------------');
1028
+ lines.push(' WHAT IS GOOD');
1029
+ lines.push('-------------------------------------');
1030
+ for (const g of report.goodPractices) {
1031
+ lines.push(` [OK] ${g}`);
1032
+ }
1033
+ lines.push('');
1034
+ }
1035
+ lines.push('-------------------------------------');
1036
+ lines.push(` Summary: ${report.summary}`);
1037
+ lines.push('-------------------------------------');
1038
+ return lines.join('\n');
1039
+ }
1040
+ function generateScoreBar(score) {
1041
+ const filled = Math.round(score / 5);
1042
+ const empty = 20 - filled;
1043
+ return '[' + '#'.repeat(filled) + '-'.repeat(empty) + ']';
1044
+ }
1045
+ // ── scheduleSecurityScan ──
1046
+ let scanInterval = null;
1047
+ /**
1048
+ * Set up recurring security scans.
1049
+ * Results saved to ~/.kbot/security/scan-history.json.
1050
+ * Sends Discord webhook alert if critical findings detected.
1051
+ */
1052
+ export function scheduleSecurityScan(intervalHours, discordWebhookUrl) {
1053
+ ensureDir(SECURITY_DIR);
1054
+ // Stop any existing schedule
1055
+ if (scanInterval) {
1056
+ clearInterval(scanInterval);
1057
+ scanInterval = null;
1058
+ }
1059
+ async function executeScan() {
1060
+ try {
1061
+ const report = await runSecurityScan({ skipBrowser: true, skipNpm: true });
1062
+ // Save to history
1063
+ const history = loadScanHistory();
1064
+ history.push({
1065
+ timestamp: report.timestamp,
1066
+ score: report.score,
1067
+ criticalCount: report.criticalCount,
1068
+ highCount: report.highCount,
1069
+ mediumCount: report.mediumCount,
1070
+ lowCount: report.lowCount,
1071
+ totalFindings: report.findings.length,
1072
+ });
1073
+ // Keep last 100 entries
1074
+ if (history.length > 100)
1075
+ history.splice(0, history.length - 100);
1076
+ saveScanHistory(history);
1077
+ // Alert on critical findings
1078
+ if (report.criticalCount > 0 && discordWebhookUrl) {
1079
+ try {
1080
+ const alertBody = {
1081
+ content: null,
1082
+ embeds: [{
1083
+ title: 'kbot Security Alert',
1084
+ description: `**${report.criticalCount} critical finding(s)** detected during scheduled scan.\n\nScore: ${report.score}/100\n\n${report.findings
1085
+ .filter(f => f.severity === 'critical')
1086
+ .map(f => `- **${f.title}**: ${f.description}`)
1087
+ .join('\n')}`,
1088
+ color: 0xFF0000, // Red
1089
+ timestamp: report.timestamp,
1090
+ }],
1091
+ };
1092
+ await fetch(discordWebhookUrl, {
1093
+ method: 'POST',
1094
+ headers: { 'Content-Type': 'application/json' },
1095
+ body: JSON.stringify(alertBody),
1096
+ signal: AbortSignal.timeout(10_000),
1097
+ });
1098
+ }
1099
+ catch {
1100
+ // Cannot send alert — log locally
1101
+ try {
1102
+ appendFileSync(join(SECURITY_DIR, 'alert-failures.log'), `${new Date().toISOString()} Failed to send Discord alert for ${report.criticalCount} critical finding(s)\n`);
1103
+ }
1104
+ catch { /* fail silently */ }
1105
+ }
1106
+ }
1107
+ }
1108
+ catch {
1109
+ // Scan failed — log error
1110
+ try {
1111
+ appendFileSync(join(SECURITY_DIR, 'scan-errors.log'), `${new Date().toISOString()} Scheduled scan failed\n`);
1112
+ }
1113
+ catch { /* fail silently */ }
1114
+ }
1115
+ }
1116
+ // Run immediately, then on interval
1117
+ void executeScan();
1118
+ const intervalMs = intervalHours * 60 * 60 * 1000;
1119
+ scanInterval = setInterval(() => { void executeScan(); }, intervalMs);
1120
+ return {
1121
+ stop() {
1122
+ if (scanInterval) {
1123
+ clearInterval(scanInterval);
1124
+ scanInterval = null;
1125
+ }
1126
+ },
1127
+ };
1128
+ }
1129
+ // ── Scan History ──
1130
+ function loadScanHistory() {
1131
+ const path = SCAN_HISTORY;
1132
+ if (!existsSync(path))
1133
+ return [];
1134
+ try {
1135
+ return JSON.parse(readFileSync(path, 'utf-8'));
1136
+ }
1137
+ catch {
1138
+ return [];
1139
+ }
1140
+ }
1141
+ function saveScanHistory(history) {
1142
+ ensureDir(SECURITY_DIR);
1143
+ writeFileSync(SCAN_HISTORY, JSON.stringify(history, null, 2));
1144
+ }
1145
+ /**
1146
+ * Get the scan history for trend analysis.
1147
+ */
1148
+ export function getScanHistory(limit = 20) {
1149
+ return loadScanHistory().slice(-limit);
1150
+ }
1151
+ //# sourceMappingURL=personal-security.js.map