penthera 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +561 -0
  4. package/SECURITY.md +31 -0
  5. package/bin/penthera.js +3 -0
  6. package/bin/scan.js +11 -0
  7. package/docs/index.md +60 -0
  8. package/docs/owasp-wstg-coverage.md +79 -0
  9. package/lib/adaptive-probes.js +334 -0
  10. package/lib/attack-surface.js +149 -0
  11. package/lib/audit-loop.js +170 -0
  12. package/lib/blackbox/adaptive-scan.js +137 -0
  13. package/lib/blackbox/client-auth.js +60 -0
  14. package/lib/blackbox/email-dns.js +135 -0
  15. package/lib/blackbox/headers.js +91 -0
  16. package/lib/blackbox/idor.js +113 -0
  17. package/lib/blackbox/jwt.js +73 -0
  18. package/lib/blackbox/oauth.js +50 -0
  19. package/lib/blackbox/openapi.js +227 -0
  20. package/lib/compliance/incident.js +169 -0
  21. package/lib/compliance/index.js +163 -0
  22. package/lib/compliance/policy-pack.js +293 -0
  23. package/lib/compliance/questionnaire.js +113 -0
  24. package/lib/compliance/readiness.js +229 -0
  25. package/lib/compliance/self-assessment.js +66 -0
  26. package/lib/crawler.js +257 -0
  27. package/lib/fuzzer.js +233 -0
  28. package/lib/injections.js +676 -0
  29. package/lib/jurisdictions/nl.json +48 -0
  30. package/lib/knowledge-graph.js +232 -0
  31. package/lib/machine.js +814 -0
  32. package/lib/nuclei-loader.js +283 -0
  33. package/lib/owasp-wstg.js +204 -0
  34. package/lib/params.js +235 -0
  35. package/lib/plugins.js +70 -0
  36. package/lib/provenance.js +82 -0
  37. package/lib/recon.js +193 -0
  38. package/lib/remediation/index.js +219 -0
  39. package/lib/retirejs.js +239 -0
  40. package/lib/supply-chain.js +131 -0
  41. package/lib/templates.js +364 -0
  42. package/lib/tls.js +145 -0
  43. package/lib/whitebox/frameworks.js +139 -0
  44. package/lib/whitebox/sbom.js +144 -0
  45. package/lib/whitebox/secrets.js +111 -0
  46. package/package.json +76 -0
  47. package/pentest.config.example.js +82 -0
  48. package/scripts/package-skill.sh +16 -0
  49. package/skills/penthera/SKILL.md +272 -0
  50. package/skills/penthera/references/authorization.md +36 -0
  51. package/skills/penthera/references/output-and-ci.md +109 -0
  52. package/skills/penthera/references/profiles-and-flags.md +74 -0
  53. package/skills/penthera/references/remediation.md +267 -0
  54. package/skills/penthera/references/reporting.md +113 -0
  55. package/skills/penthera/references/secure-defaults.md +41 -0
  56. package/skills/penthera/references/troubleshooting.md +100 -0
  57. package/skills/penthera/scripts/preflight.sh +66 -0
  58. package/skills/penthera/scripts/validate-report.mjs +53 -0
  59. package/skills/penthera/scripts/validate-report.sh +8 -0
  60. package/src/cli/ansi.js +70 -0
  61. package/src/cli/baseline.js +37 -0
  62. package/src/cli/merge-results.js +71 -0
  63. package/src/cli/onboarding.js +216 -0
  64. package/src/cli/profiles.js +53 -0
  65. package/src/cli/prompt.js +64 -0
  66. package/src/cli/run-scan.js +152 -0
  67. package/src/cli.js +536 -0
  68. package/src/report/markdown.js +182 -0
  69. package/src/reporter.js +512 -0
  70. package/src/scan-repo.js +205 -0
  71. package/src/scan-url.js +563 -0
  72. package/src/utils/auth.js +13 -0
  73. package/src/utils/http.js +38 -0
  74. package/src/utils/url.js +54 -0
package/lib/machine.js ADDED
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Penthera, Internal Machine Security Audit
3
+ *
4
+ * Checks the local macOS machine for keyloggers, trojans, rootkits,
5
+ * suspicious persistence, unauthorized network activity, and weak
6
+ * security posture.
7
+ *
8
+ * Two tiers:
9
+ * 1. Built-in macOS tools (zero installs needed):
10
+ * csrutil, spctl, xprotect, codesign, lsof, profiles, etc.
11
+ *
12
+ * 2. Optional open-source tools (if installed):
13
+ * osquery, ClamAV, rkhunter, chkrootkit
14
+ *
15
+ * Everything runs locally. No API keys, no cloud, no tokens.
16
+ */
17
+
18
+ import { execSync, execFileSync } from "node:child_process";
19
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
20
+ import { join, basename } from "node:path";
21
+ import { homedir } from "node:os";
22
+
23
+ // ── Helpers ─────────────────────────────────────────────────────────────
24
+
25
+ function run(cmd, opts = {}) {
26
+ try {
27
+ return execSync(cmd, {
28
+ encoding: "utf-8",
29
+ timeout: opts.timeout || 30_000,
30
+ maxBuffer: 10 * 1024 * 1024,
31
+ stdio: ["pipe", "pipe", "pipe"],
32
+ }).trim();
33
+ } catch (e) {
34
+ return e.stdout?.trim?.() || "";
35
+ }
36
+ }
37
+
38
+ function runFile(file, args = [], opts = {}) {
39
+ try {
40
+ return execFileSync(file, args, {
41
+ encoding: "utf-8",
42
+ timeout: opts.timeout || 30_000,
43
+ maxBuffer: 10 * 1024 * 1024,
44
+ stdio: ["pipe", "pipe", "pipe"],
45
+ }).trim();
46
+ } catch (e) {
47
+ return e.stdout?.trim?.() || "";
48
+ }
49
+ }
50
+
51
+ function which(bin) {
52
+ try {
53
+ const p = execSync(`which ${bin}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
54
+ return p || null;
55
+ } catch { return null; }
56
+ }
57
+
58
+ function safeReadDir(dir) {
59
+ try { return readdirSync(dir); } catch { return []; }
60
+ }
61
+
62
+ function safeReadFile(path) {
63
+ try { return readFileSync(path, "utf-8"); } catch { return ""; }
64
+ }
65
+
66
+ // ── 1. System Integrity Protection ──────────────────────────────────────
67
+
68
+ function checkSIP() {
69
+ const out = run("csrutil status");
70
+ const enabled = out.includes("enabled");
71
+ return {
72
+ name: "System Integrity Protection (SIP)",
73
+ status: enabled ? "enabled" : "disabled",
74
+ finding: enabled ? null : {
75
+ severity: "critical",
76
+ title: "System Integrity Protection (SIP) is disabled",
77
+ description: "SIP protects core system files from modification. A disabled SIP is a major security risk and may indicate tampering.",
78
+ category: "machine-integrity",
79
+ source: "machine-audit",
80
+ },
81
+ };
82
+ }
83
+
84
+ // ── 2. Gatekeeper ───────────────────────────────────────────────────────
85
+
86
+ function checkGatekeeper() {
87
+ const out = run("spctl --status 2>&1");
88
+ const enabled = out.includes("assessments enabled");
89
+ return {
90
+ name: "Gatekeeper",
91
+ status: enabled ? "enabled" : "disabled",
92
+ finding: enabled ? null : {
93
+ severity: "high",
94
+ title: "Gatekeeper is disabled",
95
+ description: "Gatekeeper blocks unsigned/unnotarized apps. Disabling it allows any app to run, including malware.",
96
+ category: "machine-integrity",
97
+ source: "machine-audit",
98
+ },
99
+ };
100
+ }
101
+
102
+ // ── 3. Firewall ─────────────────────────────────────────────────────────
103
+
104
+ function checkFirewall() {
105
+ const out = run("/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>&1");
106
+ const enabled = out.includes("enabled");
107
+ return {
108
+ name: "Application Firewall",
109
+ status: enabled ? "enabled" : "disabled",
110
+ finding: enabled ? null : {
111
+ severity: "medium",
112
+ title: "macOS Application Firewall is disabled",
113
+ description: "The built-in firewall blocks unauthorized incoming connections. Enable it in System Settings > Network > Firewall.",
114
+ category: "machine-config",
115
+ source: "machine-audit",
116
+ },
117
+ };
118
+ }
119
+
120
+ // ── 4. FileVault (disk encryption) ──────────────────────────────────────
121
+
122
+ function checkFileVault() {
123
+ const out = run("fdesetup status 2>&1");
124
+ const on = out.includes("FileVault is On");
125
+ return {
126
+ name: "FileVault (Disk Encryption)",
127
+ status: on ? "enabled" : "disabled",
128
+ finding: on ? null : {
129
+ severity: "medium",
130
+ title: "FileVault disk encryption is off",
131
+ description: "Without FileVault, anyone with physical access can read your disk. Enable in System Settings > Privacy & Security > FileVault.",
132
+ category: "machine-config",
133
+ source: "machine-audit",
134
+ },
135
+ };
136
+ }
137
+
138
+ // ── 5. XProtect ─────────────────────────────────────────────────────────
139
+
140
+ function checkXProtect() {
141
+ const version = run("xprotect version 2>&1");
142
+ const status = run("xprotect status 2>&1");
143
+ const upToDate = !status.includes("updates available");
144
+ return {
145
+ name: "XProtect (Apple Malware Detection)",
146
+ status: version || "unknown",
147
+ detail: status,
148
+ finding: null, // XProtect is always on in modern macOS
149
+ };
150
+ }
151
+
152
+ // ── 6. LaunchAgents & LaunchDaemons (persistence) ───────────────────────
153
+
154
+ function checkPersistence() {
155
+ const home = homedir();
156
+ const dirs = [
157
+ { path: join(home, "Library/LaunchAgents"), scope: "user", risk: "medium" },
158
+ { path: "/Library/LaunchAgents", scope: "system", risk: "high" },
159
+ { path: "/Library/LaunchDaemons", scope: "system", risk: "high" },
160
+ ];
161
+
162
+ const items = [];
163
+ const findings = [];
164
+
165
+ // Known legitimate prefixes
166
+ const KNOWN_PREFIXES = [
167
+ "com.apple.", "com.google.", "com.microsoft.", "com.adobe.",
168
+ "com.docker.", "com.github.", "com.spotify.", "com.dropbox.",
169
+ "com.1password.", "com.objective-see.", "org.mozilla.", "com.brave.",
170
+ "com.figma.", "com.notion.", "com.slack.", "com.zoom.", "us.zoom.",
171
+ "com.logi.", "com.logitech.", "com.valvesoftware.", "org.virtualbox.",
172
+ "com.parallels.", "io.tailscale.", "com.cloudflare.",
173
+ "homebrew.", "org.nix.", "com.nordvpn.", "com.expressvpn.",
174
+ "com.openai.", "ai.openclaw.", "com.anthropic.",
175
+ "com.autodesk.", "com.wibu.", "com.jetbrains.",
176
+ "com.linear.", "com.raycast.", "com.todoist.",
177
+ "com.elgato.", "com.corsair.", "com.steelseries.",
178
+ "org.pqrs.", "com.hegenberg.", "com.knollsoft.",
179
+ ];
180
+
181
+ for (const dir of dirs) {
182
+ const files = safeReadDir(dir.path).filter((f) => f.endsWith(".plist"));
183
+ for (const file of files) {
184
+ const fullPath = join(dir.path, file);
185
+ const content = safeReadFile(fullPath);
186
+ const label = file.replace(/\.plist$/, "");
187
+
188
+ // Extract the program/binary it runs
189
+ const programMatch = content.match(/<key>Program<\/key>\s*<string>([^<]+)<\/string>/);
190
+ const argMatch = content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/);
191
+ const program = programMatch?.[1] || argMatch?.[1] || "unknown";
192
+
193
+ const isKnown = KNOWN_PREFIXES.some((p) => label.toLowerCase().startsWith(p));
194
+
195
+ // Check code signature of the binary
196
+ let signed = null;
197
+ if (program !== "unknown" && existsSync(program)) {
198
+ const sigOut = run(`codesign -v "${program}" 2>&1`);
199
+ signed = sigOut === "" || sigOut.includes("valid on disk"); // empty = valid
200
+ }
201
+
202
+ items.push({
203
+ label,
204
+ path: fullPath,
205
+ program,
206
+ scope: dir.scope,
207
+ known: isKnown,
208
+ signed,
209
+ });
210
+
211
+ // Flag suspicious items
212
+ if (!isKnown) {
213
+ const severity = signed === false ? "high" : dir.risk === "high" ? "medium" : "low";
214
+ findings.push({
215
+ severity,
216
+ title: `Unknown ${dir.scope} persistence: ${label}`,
217
+ description: `${fullPath} → ${program}${signed === false ? " (UNSIGNED)" : ""}`,
218
+ category: "persistence",
219
+ source: "machine-audit",
220
+ });
221
+ }
222
+ }
223
+ }
224
+
225
+ return { items, findings };
226
+ }
227
+
228
+ // ── 7. Login Items ──────────────────────────────────────────────────────
229
+
230
+ function checkLoginItems() {
231
+ // Modern macOS (13+) uses SMAppService, older uses shared file list
232
+ const out = run("osascript -e 'tell application \"System Events\" to get the name of every login item' 2>&1");
233
+ const items = out && !out.includes("error")
234
+ ? out.split(",").map((s) => s.trim()).filter(Boolean)
235
+ : [];
236
+ return { items };
237
+ }
238
+
239
+ // ── 8. Crontab ──────────────────────────────────────────────────────────
240
+
241
+ function checkCrontab() {
242
+ const out = run("crontab -l 2>&1");
243
+ const hasCron = out && !out.includes("no crontab");
244
+ const entries = hasCron ? out.split("\n").filter((l) => l.trim() && !l.startsWith("#")) : [];
245
+ const findings = entries.map((entry) => ({
246
+ severity: "info",
247
+ title: `Cron job: ${entry.slice(0, 80)}`,
248
+ description: "Review this cron entry for unexpected scheduled tasks",
249
+ category: "persistence",
250
+ source: "machine-audit",
251
+ }));
252
+ return { entries, findings };
253
+ }
254
+
255
+ // ── 9. Suspicious network connections ───────────────────────────────────
256
+
257
+ function checkNetworkConnections() {
258
+ const out = run("lsof -i -P -n -sTCP:ESTABLISHED 2>/dev/null");
259
+ const lines = out.split("\n").filter((l) => l && !l.startsWith("COMMAND"));
260
+ const connections = [];
261
+ const findings = [];
262
+
263
+ // Known suspicious ports or IPs to flag
264
+ const SUSPICIOUS_PORTS = new Set(["4444", "5555", "6666", "6667", "1337", "31337", "12345", "54321"]);
265
+
266
+ for (const line of lines) {
267
+ const parts = line.split(/\s+/);
268
+ const process = parts[0];
269
+ const pid = parts[1];
270
+ const name = parts[parts.length - 1]; // last field has the connection
271
+ const remoteMatch = name.match(/->([^:]+):(\d+)/);
272
+
273
+ if (remoteMatch) {
274
+ const ip = remoteMatch[1];
275
+ const port = remoteMatch[2];
276
+ connections.push({ process, pid, ip, port });
277
+
278
+ if (SUSPICIOUS_PORTS.has(port)) {
279
+ findings.push({
280
+ severity: "high",
281
+ title: `Suspicious outbound connection: ${process} → ${ip}:${port}`,
282
+ description: `PID ${pid} connected to port ${port} (commonly used by RATs/backdoors)`,
283
+ category: "network",
284
+ source: "machine-audit",
285
+ });
286
+ }
287
+ }
288
+ }
289
+
290
+ return { connections, findings };
291
+ }
292
+
293
+ // ── 10. Keyboard event taps (keylogger detection) ───────────────────────
294
+
295
+ function checkEventTaps() {
296
+ // CGEventTap is how keyloggers work on macOS
297
+ // We can check via ioreg or by looking for processes with Input Monitoring TCC
298
+ const findings = [];
299
+
300
+ // Check for processes with accessibility or input monitoring
301
+ // This requires checking the TCC database
302
+ const home = homedir();
303
+ const tccPath = join(home, "Library/Application Support/com.apple.TCC/TCC.db");
304
+
305
+ if (existsSync(tccPath)) {
306
+ // kTCCServiceAccessibility = accessibility access (can see keystrokes)
307
+ // kTCCServiceListenEvent / kTCCServicePostEvent = input monitoring
308
+ const services = ["kTCCServiceAccessibility", "kTCCServicePostEvent", "kTCCServiceListenEvent"];
309
+
310
+ for (const service of services) {
311
+ const out = run(`sqlite3 "${tccPath}" "SELECT client, auth_value FROM access WHERE service='${service}' AND auth_value=2" 2>&1`);
312
+ if (out && !out.includes("Error") && !out.includes("unable")) {
313
+ const lines = out.split("\n").filter(Boolean);
314
+ for (const line of lines) {
315
+ const client = line.split("|")[0];
316
+ const label = service.replace("kTCCService", "");
317
+ findings.push({
318
+ severity: "info",
319
+ title: `${label} access granted: ${client}`,
320
+ description: `App has ${label} permission, can potentially monitor keyboard input`,
321
+ category: "permissions",
322
+ source: "machine-audit",
323
+ });
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ return { findings };
330
+ }
331
+
332
+ // ── 11. Chrome extensions ───────────────────────────────────────────────
333
+
334
+ function checkBrowserExtensions() {
335
+ const home = homedir();
336
+ const extensions = [];
337
+
338
+ // Chrome
339
+ const chromeExtDir = join(home, "Library/Application Support/Google/Chrome/Default/Extensions");
340
+ const chromeExts = safeReadDir(chromeExtDir);
341
+ for (const extId of chromeExts) {
342
+ const extPath = join(chromeExtDir, extId);
343
+ const versions = safeReadDir(extPath).filter((v) => !v.startsWith("."));
344
+ if (versions.length === 0) continue;
345
+ const manifestPath = join(extPath, versions[versions.length - 1], "manifest.json");
346
+ const manifest = safeReadFile(manifestPath);
347
+ try {
348
+ const m = JSON.parse(manifest);
349
+ extensions.push({
350
+ browser: "Chrome",
351
+ id: extId,
352
+ name: m.name || extId,
353
+ version: m.version || "?",
354
+ permissions: m.permissions || [],
355
+ });
356
+ } catch { /* skip malformed */ }
357
+ }
358
+
359
+ // Flag extensions with dangerous permissions
360
+ const findings = [];
361
+ const DANGEROUS_PERMS = ["<all_urls>", "*://*/*", "webRequest", "webRequestBlocking", "nativeMessaging", "debugger", "clipboardRead"];
362
+
363
+ for (const ext of extensions) {
364
+ const dangerous = ext.permissions.filter((p) => DANGEROUS_PERMS.some((d) => p.includes?.(d)));
365
+ if (dangerous.length > 0) {
366
+ findings.push({
367
+ severity: "info",
368
+ title: `Chrome extension "${ext.name}" has broad permissions`,
369
+ description: `Permissions: ${dangerous.join(", ")}`,
370
+ category: "browser-extensions",
371
+ source: "machine-audit",
372
+ });
373
+ }
374
+ }
375
+
376
+ return { extensions, findings };
377
+ }
378
+
379
+ // ── 12. MDM / Configuration Profiles ────────────────────────────────────
380
+
381
+ function checkProfiles() {
382
+ const out = run("profiles status -type enrollment 2>&1");
383
+ const enrolled = out.includes("Yes");
384
+ const findings = [];
385
+
386
+ if (enrolled) {
387
+ findings.push({
388
+ severity: "info",
389
+ title: "Device is enrolled in MDM",
390
+ description: "This Mac is managed by a Mobile Device Management server. Check System Settings > Profiles to review.",
391
+ category: "machine-config",
392
+ source: "machine-audit",
393
+ });
394
+ }
395
+
396
+ return { enrolled, findings };
397
+ }
398
+
399
+ // ── 13. Suspicious processes ────────────────────────────────────────────
400
+
401
+ function checkProcesses() {
402
+ const out = run("ps aux 2>/dev/null");
403
+ const lines = out.split("\n").filter((l) => l && !l.startsWith("USER"));
404
+ const findings = [];
405
+
406
+ // Look for processes running from suspicious locations
407
+ const SUSPICIOUS_PATHS = ["/tmp/", "/var/tmp/", "/dev/shm/", "/.hidden", "/Users/Shared/"];
408
+ // Known-safe locations that use tmp-like paths
409
+ const SAFE_PATH_PATTERNS = [".claude/", ".vscode/", ".cursor/", "node_modules/", "nix/store/"];
410
+ // Match full binary name only (not substrings like "Sync", "Launch")
411
+ const SUSPICIOUS_NAMES = [
412
+ "ncat", "netcat", "socat", "meterpreter", "beacon",
413
+ "cobaltstrike", "mimikatz", "lazagne", "keylogger",
414
+ "reverse_shell", "bind_shell", "rat_server",
415
+ ];
416
+
417
+ for (const line of lines) {
418
+ const parts = line.split(/\s+/);
419
+ const processName = parts[10] || "";
420
+ const cmd = parts.slice(10).join(" ");
421
+
422
+ for (const path of SUSPICIOUS_PATHS) {
423
+ if (cmd.includes(path) && !SAFE_PATH_PATTERNS.some((s) => cmd.includes(s))) {
424
+ findings.push({
425
+ severity: "high",
426
+ title: `Process running from suspicious location`,
427
+ description: `${cmd.slice(0, 120)}`,
428
+ category: "suspicious-process",
429
+ source: "machine-audit",
430
+ });
431
+ break;
432
+ }
433
+ }
434
+
435
+ // Extract the actual binary name from the path
436
+ const binaryName = basename(processName).toLowerCase();
437
+ for (const name of SUSPICIOUS_NAMES) {
438
+ if (binaryName === name || binaryName === `${name}.exe`) {
439
+ findings.push({
440
+ severity: "critical",
441
+ title: `Suspicious process detected: ${name}`,
442
+ description: `${cmd.slice(0, 120)}`,
443
+ category: "suspicious-process",
444
+ source: "machine-audit",
445
+ });
446
+ break;
447
+ }
448
+ }
449
+
450
+ // Also check for netcat specifically, `nc` only if it's the exact process name
451
+ if (binaryName === "nc") {
452
+ findings.push({
453
+ severity: "high",
454
+ title: "Netcat (nc) process running",
455
+ description: `${cmd.slice(0, 120)}`,
456
+ category: "suspicious-process",
457
+ source: "machine-audit",
458
+ });
459
+ }
460
+ }
461
+
462
+ return { count: lines.length, findings };
463
+ }
464
+
465
+ // ── 14. Recently modified executables in sensitive dirs ──────────────────
466
+
467
+ function checkRecentlyModified() {
468
+ const findings = [];
469
+ const dirs = ["/usr/local/bin", "/opt/homebrew/bin", "/tmp", "/var/tmp"];
470
+
471
+ for (const dir of dirs) {
472
+ if (!existsSync(dir)) continue;
473
+ // Find files modified in the last 24 hours
474
+ const out = run(`find "${dir}" -maxdepth 1 -type f -mtime -1 2>/dev/null`);
475
+ const files = out.split("\n").filter(Boolean);
476
+
477
+ for (const file of files) {
478
+ if (dir.includes("tmp")) {
479
+ // Only flag executables in tmp
480
+ try {
481
+ const st = statSync(file);
482
+ if (st.mode & 0o111) { // executable bit set
483
+ findings.push({
484
+ severity: "medium",
485
+ title: `Executable in temp directory: ${basename(file)}`,
486
+ description: `${file}, recently modified executable in ${dir}`,
487
+ category: "suspicious-files",
488
+ source: "machine-audit",
489
+ });
490
+ }
491
+ } catch { /* skip */ }
492
+ }
493
+ }
494
+ }
495
+
496
+ return { findings };
497
+ }
498
+
499
+ // ── 15. Optional: osquery ───────────────────────────────────────────────
500
+
501
+ async function checkOsquery() {
502
+ const bin = which("osqueryi");
503
+ if (!bin) return { available: false, results: {} };
504
+
505
+ const queries = {
506
+ listening_ports: "SELECT p.name, p.path, l.port, l.protocol, l.address FROM listening_ports l JOIN processes p ON l.pid = p.pid WHERE l.port != 0",
507
+ kernel_extensions: "SELECT name, version, linked FROM kernel_extensions WHERE name NOT LIKE 'com.apple.%'",
508
+ unsigned_apps: "SELECT path, authority FROM signature WHERE authority = '' AND path LIKE '/Applications/%'",
509
+ ssh_keys: "SELECT * FROM user_ssh_keys",
510
+ };
511
+
512
+ const results = {};
513
+ for (const [name, query] of Object.entries(queries)) {
514
+ const out = run(`osqueryi --json "${query}" 2>/dev/null`, { timeout: 15_000 });
515
+ try {
516
+ results[name] = JSON.parse(out);
517
+ } catch {
518
+ results[name] = [];
519
+ }
520
+ }
521
+
522
+ return { available: true, results };
523
+ }
524
+
525
+ // ── 16. Optional: ClamAV quick scan ─────────────────────────────────────
526
+
527
+ function checkClamAV(scanPath, opts = {}) {
528
+ const bin = which("clamscan");
529
+ if (!bin) return { available: false, infected: [] };
530
+
531
+ const timeout = opts.timeout || 120_000;
532
+ const out = run(`clamscan -r -i --no-summary "${scanPath}" 2>/dev/null`, { timeout });
533
+ const infected = out.split("\n")
534
+ .filter((l) => l.includes("FOUND"))
535
+ .map((l) => {
536
+ const [file, sig] = l.split(": ");
537
+ return { file: file?.trim(), signature: sig?.replace(" FOUND", "").trim() };
538
+ })
539
+ .filter((r) => r.file);
540
+
541
+ return { available: true, infected };
542
+ }
543
+
544
+ // ── 17. Optional: rkhunter ──────────────────────────────────────────────
545
+
546
+ // Known macOS false positives, rkhunter was built for Linux and doesn't
547
+ // understand many normal macOS behaviors. We filter these out so only
548
+ // genuinely suspicious findings surface.
549
+ const RKHUNTER_MACOS_FP = [
550
+ // Normal macOS: Apple ships these as scripts, not binaries
551
+ /replaced by a script.*\/usr\/bin\/(fuser|whatis|shasum|lsvfs)/i,
552
+ // Normal macOS: launchd, not init.d
553
+ /No system startup files found/i,
554
+ // Normal macOS: ControlCenter AirPlay Receiver on port 5000/7000
555
+ /port (5000|7000) is being used by.*ControlCenter/i,
556
+ // Normal macOS: Thunderbolt bridge interfaces are always promiscuous
557
+ /Possible promiscuous interfaces/i,
558
+ // ifconfig dump lines (follow-on from promiscuous warning)
559
+ /^\s*(lo0|gif0|stf0|anpi\d|en\d|bridge\d|ap\d|awdl\d|llw\d|utun\d|vmnet\d):/,
560
+ /^\s+(options|ether|inet|nd6|media|status|Configuration|member|id |maxage|root |ipfilter|ifmaxaddr)/,
561
+ /^\s+flags=/,
562
+ // Normal macOS: stock hidden man page
563
+ /Hidden file found:.*\/usr\/share\/man/i,
564
+ // rkhunter setup messages (not actual threats)
565
+ /rkhunter\.dat.*does not exist/i,
566
+ /users responsibility to ensure/i,
567
+ /propupd.*option/i,
568
+ /files on their system are known to be genuine/i,
569
+ /compare the current file properties/i,
570
+ /report if any values differ/i,
571
+ /cannot determine what has caused the change/i,
572
+ /Checking for prerequisites\s+\[/i,
573
+ /Checking for possible rootkit strings\s+\[/i,
574
+ // ifconfig noise lines
575
+ /^\s+\t/,
576
+ // Continuation lines from the promiscuous dump
577
+ /ifconfig.*command output/i,
578
+ /Use the 'lsof -i'/i,
579
+ ];
580
+
581
+ function isRkhunterFalsePositive(line) {
582
+ return RKHUNTER_MACOS_FP.some((re) => re.test(line));
583
+ }
584
+
585
+ function classifyRkhunterWarning(line) {
586
+ // SSH misconfig
587
+ if (/SSH.*PermitRootLogin/i.test(line)) return { severity: "medium", category: "ssh-config" };
588
+ if (/SSH.*Protocol/i.test(line)) return { severity: "low", category: "ssh-config" };
589
+ if (/default value may be/i.test(line)) return null; // continuation line
590
+ // Actual rootkit signatures
591
+ if (/rootkit.*found/i.test(line) && !/not found/i.test(line)) return { severity: "critical", category: "rootkit" };
592
+ // Suspicious files
593
+ if (/Hidden file found/i.test(line)) return { severity: "medium", category: "hidden-files" };
594
+ if (/suspicious.*string/i.test(line)) return { severity: "high", category: "rootkit" };
595
+ // Suspicious ports
596
+ if (/Network.*port.*is being used/i.test(line)) return { severity: "medium", category: "network" };
597
+ // Modified system commands
598
+ if (/replaced by a script/i.test(line)) return { severity: "medium", category: "system-commands" };
599
+ // Default: medium
600
+ return { severity: "medium", category: "rkhunter" };
601
+ }
602
+
603
+ function checkRkhunter(progress) {
604
+ const bin = which("rkhunter");
605
+ if (!bin) return { available: false, warnings: [], findings: [] };
606
+
607
+ // Auto-initialize baseline if missing
608
+ const datExists = run("ls /var/db/rkhunter/db/rkhunter.dat 2>/dev/null || ls /opt/homebrew/var/lib/rkhunter/db/rkhunter.dat 2>/dev/null || ls /usr/local/var/lib/rkhunter/db/rkhunter.dat 2>/dev/null");
609
+ if (!datExists) {
610
+ progress?.("Initializing rkhunter baseline (first run)...");
611
+ run("sudo rkhunter --propupd 2>/dev/null", { timeout: 60_000 });
612
+ }
613
+
614
+ const out = run("sudo rkhunter --check --sk --rwo 2>/dev/null", { timeout: 120_000 });
615
+ const rawLines = out.split("\n").filter(Boolean);
616
+
617
+ // Filter macOS false positives
618
+ const realWarnings = rawLines.filter((l) => !isRkhunterFalsePositive(l));
619
+
620
+ // Deduplicate and classify
621
+ const findings = [];
622
+ const seen = new Set();
623
+ for (const line of realWarnings) {
624
+ const trimmed = line.replace(/^Warning:\s*/i, "").trim();
625
+ if (!trimmed || trimmed.length < 10) continue;
626
+ if (seen.has(trimmed)) continue;
627
+ seen.add(trimmed);
628
+
629
+ const classification = classifyRkhunterWarning(line);
630
+ if (!classification) continue;
631
+
632
+ findings.push({
633
+ severity: classification.severity,
634
+ title: `rkhunter: ${trimmed.slice(0, 100)}`,
635
+ description: trimmed,
636
+ category: classification.category,
637
+ source: "rkhunter",
638
+ });
639
+ }
640
+
641
+ return {
642
+ available: true,
643
+ rawWarnings: rawLines.length,
644
+ filteredWarnings: rawLines.length - realWarnings.length,
645
+ warnings: realWarnings,
646
+ findings,
647
+ };
648
+ }
649
+
650
+ // ── Aggregate: Full Machine Audit ───────────────────────────────────────
651
+
652
+ /**
653
+ * Run a full machine security audit.
654
+ *
655
+ * @param {object} opts - { onPhase, clamScanPath, skipOptional }
656
+ * @returns {{ checks: object[], findings: object[], tools: object }}
657
+ */
658
+ export async function auditMachine(opts = {}) {
659
+ const progress = opts.onPhase || (() => {});
660
+ const checks = [];
661
+ const findings = [];
662
+
663
+ // Track available optional tools
664
+ const tools = {
665
+ osquery: !!which("osqueryi"),
666
+ clamav: !!which("clamscan"),
667
+ rkhunter: !!which("rkhunter"),
668
+ chkrootkit: !!which("chkrootkit"),
669
+ };
670
+
671
+ // ── Built-in checks (no installs needed) ──────────────────────────────
672
+
673
+ progress("Checking System Integrity Protection (SIP)...");
674
+ const sip = checkSIP();
675
+ checks.push(sip);
676
+ if (sip.finding) findings.push(sip.finding);
677
+
678
+ progress("Checking Gatekeeper...");
679
+ const gk = checkGatekeeper();
680
+ checks.push(gk);
681
+ if (gk.finding) findings.push(gk.finding);
682
+
683
+ progress("Checking firewall...");
684
+ const fw = checkFirewall();
685
+ checks.push(fw);
686
+ if (fw.finding) findings.push(fw.finding);
687
+
688
+ progress("Checking FileVault disk encryption...");
689
+ const fv = checkFileVault();
690
+ checks.push(fv);
691
+ if (fv.finding) findings.push(fv.finding);
692
+
693
+ progress("Checking XProtect...");
694
+ const xp = checkXProtect();
695
+ checks.push(xp);
696
+
697
+ progress("Scanning LaunchAgents & LaunchDaemons (persistence)...");
698
+ const persistence = checkPersistence();
699
+ checks.push({ name: "Persistence (LaunchAgents/Daemons)", count: persistence.items.length });
700
+ findings.push(...persistence.findings);
701
+
702
+ progress("Checking login items...");
703
+ const loginItems = checkLoginItems();
704
+ checks.push({ name: "Login Items", items: loginItems.items });
705
+
706
+ progress("Checking crontab...");
707
+ const cron = checkCrontab();
708
+ checks.push({ name: "Crontab", entries: cron.entries.length });
709
+ findings.push(...cron.findings);
710
+
711
+ progress("Scanning network connections...");
712
+ const network = checkNetworkConnections();
713
+ checks.push({ name: "Network Connections", count: network.connections.length });
714
+ findings.push(...network.findings);
715
+
716
+ progress("Checking for keylogger indicators (event taps & TCC)...");
717
+ const eventTaps = checkEventTaps();
718
+ findings.push(...eventTaps.findings);
719
+
720
+ progress("Auditing browser extensions...");
721
+ const browser = checkBrowserExtensions();
722
+ checks.push({ name: "Browser Extensions", count: browser.extensions.length });
723
+ findings.push(...browser.findings);
724
+
725
+ progress("Checking MDM enrollment...");
726
+ const mdm = checkProfiles();
727
+ findings.push(...mdm.findings);
728
+
729
+ progress("Scanning running processes...");
730
+ const procs = checkProcesses();
731
+ checks.push({ name: "Running Processes", count: procs.count });
732
+ findings.push(...procs.findings);
733
+
734
+ progress("Checking for suspicious executables...");
735
+ const recent = checkRecentlyModified();
736
+ findings.push(...recent.findings);
737
+
738
+ // ── Optional tools (if installed) ─────────────────────────────────────
739
+
740
+ if (!opts.skipOptional) {
741
+ if (tools.osquery) {
742
+ progress("Running osquery security queries...");
743
+ const osq = await checkOsquery();
744
+ checks.push({ name: "osquery", ...osq.results });
745
+
746
+ // Flag unsigned apps
747
+ for (const app of osq.results.unsigned_apps || []) {
748
+ findings.push({
749
+ severity: "medium",
750
+ title: `Unsigned application: ${basename(app.path)}`,
751
+ description: app.path,
752
+ category: "code-signing",
753
+ source: "osquery",
754
+ });
755
+ }
756
+
757
+ // Flag non-Apple kernel extensions
758
+ for (const kext of osq.results.kernel_extensions || []) {
759
+ findings.push({
760
+ severity: "info",
761
+ title: `Third-party kernel extension: ${kext.name}`,
762
+ description: `Version ${kext.version}`,
763
+ category: "kernel",
764
+ source: "osquery",
765
+ });
766
+ }
767
+ }
768
+
769
+ if (tools.clamav) {
770
+ const scanPath = opts.clamScanPath || homedir();
771
+ progress(`Running ClamAV scan on ${scanPath} (this may take a while)...`);
772
+ const clam = checkClamAV(scanPath, { timeout: 300_000 });
773
+ checks.push({ name: "ClamAV", scanned: scanPath, infected: clam.infected.length });
774
+
775
+ for (const hit of clam.infected) {
776
+ findings.push({
777
+ severity: "critical",
778
+ title: `Malware detected: ${hit.signature}`,
779
+ description: hit.file,
780
+ category: "malware",
781
+ source: "clamav",
782
+ });
783
+ }
784
+ }
785
+
786
+ if (tools.rkhunter) {
787
+ progress("Running rkhunter rootkit scan...");
788
+ const rk = checkRkhunter(progress);
789
+ checks.push({
790
+ name: "rkhunter",
791
+ realWarnings: rk.findings.length,
792
+ filteredFalsePositives: rk.filteredWarnings || 0,
793
+ });
794
+ findings.push(...rk.findings);
795
+ }
796
+ }
797
+
798
+ // ── Missing tools advisory ────────────────────────────────────────────
799
+ const missing = [];
800
+ if (!tools.osquery) missing.push("osquery (brew install --cask osquery)");
801
+ if (!tools.clamav) missing.push("ClamAV (brew install clamav)");
802
+ if (!tools.rkhunter) missing.push("rkhunter (brew install rkhunter)");
803
+
804
+ return {
805
+ checks,
806
+ findings,
807
+ tools,
808
+ missing,
809
+ persistence: persistence.items,
810
+ network: network.connections,
811
+ browserExtensions: browser.extensions,
812
+ loginItems: loginItems.items,
813
+ };
814
+ }