@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.
- package/README.md +5 -5
- package/dist/agent-teams.d.ts +1 -1
- package/dist/agent-teams.d.ts.map +1 -1
- package/dist/agent-teams.js +36 -3
- package/dist/agent-teams.js.map +1 -1
- package/dist/agents/specialists.d.ts.map +1 -1
- package/dist/agents/specialists.js +20 -0
- package/dist/agents/specialists.js.map +1 -1
- package/dist/channels/kbot-channel.js +8 -31
- package/dist/channels/kbot-channel.js.map +1 -1
- package/dist/cli.js +8 -8
- package/dist/digest.js +1 -1
- package/dist/digest.js.map +1 -1
- package/dist/email-service.d.ts.map +1 -1
- package/dist/email-service.js +1 -2
- package/dist/email-service.js.map +1 -1
- package/dist/episodic-memory.d.ts.map +1 -1
- package/dist/episodic-memory.js +14 -0
- package/dist/episodic-memory.js.map +1 -1
- package/dist/interactive-buttons.d.ts +90 -0
- package/dist/interactive-buttons.d.ts.map +1 -0
- package/dist/interactive-buttons.js +286 -0
- package/dist/interactive-buttons.js.map +1 -0
- package/dist/learned-router.d.ts.map +1 -1
- package/dist/learned-router.js +29 -0
- package/dist/learned-router.js.map +1 -1
- package/dist/memory-hotswap.d.ts +58 -0
- package/dist/memory-hotswap.d.ts.map +1 -0
- package/dist/memory-hotswap.js +288 -0
- package/dist/memory-hotswap.js.map +1 -0
- package/dist/personal-security.d.ts +142 -0
- package/dist/personal-security.d.ts.map +1 -0
- package/dist/personal-security.js +1151 -0
- package/dist/personal-security.js.map +1 -0
- package/dist/side-conversation.d.ts +58 -0
- package/dist/side-conversation.d.ts.map +1 -0
- package/dist/side-conversation.js +224 -0
- package/dist/side-conversation.js.map +1 -0
- package/dist/tools/email.d.ts.map +1 -1
- package/dist/tools/email.js +2 -3
- package/dist/tools/email.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +7 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/lab-bio.d.ts +2 -0
- package/dist/tools/lab-bio.d.ts.map +1 -0
- package/dist/tools/lab-bio.js +1392 -0
- package/dist/tools/lab-bio.js.map +1 -0
- package/dist/tools/lab-chem.d.ts +2 -0
- package/dist/tools/lab-chem.d.ts.map +1 -0
- package/dist/tools/lab-chem.js +1257 -0
- package/dist/tools/lab-chem.js.map +1 -0
- package/dist/tools/lab-core.d.ts +2 -0
- package/dist/tools/lab-core.d.ts.map +1 -0
- package/dist/tools/lab-core.js +2452 -0
- package/dist/tools/lab-core.js.map +1 -0
- package/dist/tools/lab-data.d.ts +2 -0
- package/dist/tools/lab-data.d.ts.map +1 -0
- package/dist/tools/lab-data.js +2464 -0
- package/dist/tools/lab-data.js.map +1 -0
- package/dist/tools/lab-earth.d.ts +2 -0
- package/dist/tools/lab-earth.d.ts.map +1 -0
- package/dist/tools/lab-earth.js +1124 -0
- package/dist/tools/lab-earth.js.map +1 -0
- package/dist/tools/lab-math.d.ts +2 -0
- package/dist/tools/lab-math.d.ts.map +1 -0
- package/dist/tools/lab-math.js +3021 -0
- package/dist/tools/lab-math.js.map +1 -0
- package/dist/tools/lab-physics.d.ts +2 -0
- package/dist/tools/lab-physics.d.ts.map +1 -0
- package/dist/tools/lab-physics.js +2423 -0
- package/dist/tools/lab-physics.js.map +1 -0
- 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
|