|
| 1 | +// Copyright 2025-2026 Andrey Vasilevsky <anvanster@gmail.com> |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +/** |
| 5 | + * Machine-profile detection for `engagement.machineProfile`. |
| 6 | + * |
| 7 | + * Purpose: fingerprint the graph_load crash cohort — which classes of machine |
| 8 | + * corrupt their graph.db (cloud-synced data dir, third-party AV holding file |
| 9 | + * handles, VM/virtual-disk fsync semantics, low RAM). Each detector MAY read a |
| 10 | + * path, a MAC OUI, or an AV product name to CLASSIFY, but only ever returns a |
| 11 | + * fixed enum / coarse number. No path, MAC, hostname, product name, or exact |
| 12 | + * byte count ever leaves this module — the reporter buckets/allowlists the |
| 13 | + * output again before it is sent. Best-effort; never throws. |
| 14 | + */ |
| 15 | + |
| 16 | +import * as os from 'os'; |
| 17 | +import * as fs from 'fs'; |
| 18 | +import * as path from 'path'; |
| 19 | +import { execFile } from 'child_process'; |
| 20 | + |
| 21 | +export interface MachineProfile { |
| 22 | + /** local | cloud_onedrive | cloud_other | unc_network | unknown */ |
| 23 | + dataDirKind: string; |
| 24 | + /** physical | vm | wsl | container | unknown */ |
| 25 | + machineKind: string; |
| 26 | + /** total physical RAM in GB (reporter buckets it) */ |
| 27 | + totalRamGb: number; |
| 28 | + /** defender_only | third_party | none | unknown (Windows only) */ |
| 29 | + antivirusKind: string; |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Classify where `~/.codegraph` lives WITHOUT logging the path. Cloud-sync |
| 34 | + * agents (OneDrive Known Folder Move, Dropbox, …) and network/UNC profiles |
| 35 | + * hold handles + rewrite files under RocksDB → torn-write corruption. |
| 36 | + */ |
| 37 | +function detectDataDirKind(): string { |
| 38 | + try { |
| 39 | + const dir = path.join(os.homedir(), '.codegraph'); |
| 40 | + if (dir.startsWith('\\\\')) return 'unc_network'; |
| 41 | + const lower = dir.toLowerCase(); |
| 42 | + const oneDriveEnv = [ |
| 43 | + process.env.OneDrive, |
| 44 | + process.env.OneDriveConsumer, |
| 45 | + process.env.OneDriveCommercial, |
| 46 | + ] |
| 47 | + .filter((v): v is string => !!v) |
| 48 | + .map((v) => v.toLowerCase()); |
| 49 | + if (oneDriveEnv.some((p) => lower.startsWith(p)) || lower.includes('onedrive')) { |
| 50 | + return 'cloud_onedrive'; |
| 51 | + } |
| 52 | + if (/dropbox|google[ _]?drive|\bbox\b|pcloud|icloud|nextcloud|\bmega\b/.test(lower)) { |
| 53 | + return 'cloud_other'; |
| 54 | + } |
| 55 | + return 'local'; |
| 56 | + } catch { |
| 57 | + return 'unknown'; |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +const VM_MAC_OUI_PREFIXES = [ |
| 62 | + '00:05:69', '00:0c:29', '00:1c:14', '00:50:56', // VMware |
| 63 | + '08:00:27', '0a:00:27', // VirtualBox |
| 64 | + '00:15:5d', // Hyper-V |
| 65 | + '52:54:00', // QEMU/KVM |
| 66 | + '00:16:3e', // Xen |
| 67 | +]; |
| 68 | + |
| 69 | +/** Physical vs virtualized. MAC is read only to test its OUI prefix; the MAC |
| 70 | + * itself is never emitted. */ |
| 71 | +function detectMachineKind(): string { |
| 72 | + try { |
| 73 | + if (process.platform === 'linux') { |
| 74 | + if (process.env.WSL_DISTRO_NAME) return 'wsl'; |
| 75 | + try { |
| 76 | + const rel = os.release().toLowerCase(); |
| 77 | + if (rel.includes('microsoft') || rel.includes('wsl')) return 'wsl'; |
| 78 | + } catch { |
| 79 | + /* ignore */ |
| 80 | + } |
| 81 | + try { |
| 82 | + if (fs.existsSync('/.dockerenv')) return 'container'; |
| 83 | + const cg = fs.readFileSync('/proc/1/cgroup', 'utf8'); |
| 84 | + if (/docker|containerd|kubepods|\blxc\b/.test(cg)) return 'container'; |
| 85 | + } catch { |
| 86 | + /* ignore */ |
| 87 | + } |
| 88 | + } |
| 89 | + const ifaces = os.networkInterfaces(); |
| 90 | + for (const name of Object.keys(ifaces)) { |
| 91 | + for (const ni of ifaces[name] ?? []) { |
| 92 | + const mac = (ni.mac || '').toLowerCase(); |
| 93 | + if (mac && mac !== '00:00:00:00:00:00' && VM_MAC_OUI_PREFIXES.some((p) => mac.startsWith(p))) { |
| 94 | + return 'vm'; |
| 95 | + } |
| 96 | + } |
| 97 | + } |
| 98 | + return 'physical'; |
| 99 | + } catch { |
| 100 | + return 'unknown'; |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +/** Windows AV class via SecurityCenter2. Returns only the coarse class — never |
| 105 | + * the product name. Off the activation path (async, hard-timeout). */ |
| 106 | +function detectAntivirusKind(): Promise<string> { |
| 107 | + if (process.platform !== 'win32') return Promise.resolve('unknown'); |
| 108 | + return new Promise((resolve) => { |
| 109 | + const ps = |
| 110 | + 'Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct ' + |
| 111 | + '| Select-Object -ExpandProperty displayName'; |
| 112 | + try { |
| 113 | + execFile( |
| 114 | + 'powershell.exe', |
| 115 | + ['-NoProfile', '-NonInteractive', '-Command', ps], |
| 116 | + { timeout: 4000, windowsHide: true }, |
| 117 | + (err, stdout) => { |
| 118 | + if (err || !stdout) { |
| 119 | + resolve('unknown'); |
| 120 | + return; |
| 121 | + } |
| 122 | + const names = stdout |
| 123 | + .split(/\r?\n/) |
| 124 | + .map((s) => s.trim().toLowerCase()) |
| 125 | + .filter(Boolean); |
| 126 | + if (names.length === 0) { |
| 127 | + resolve('none'); |
| 128 | + return; |
| 129 | + } |
| 130 | + const isDefender = (n: string) => |
| 131 | + n.includes('defender') || n.includes('microsoft security'); |
| 132 | + resolve(names.some((n) => !isDefender(n)) ? 'third_party' : 'defender_only'); |
| 133 | + }, |
| 134 | + ); |
| 135 | + } catch { |
| 136 | + resolve('unknown'); |
| 137 | + } |
| 138 | + }); |
| 139 | +} |
| 140 | + |
| 141 | +export async function detectMachineProfile(): Promise<MachineProfile> { |
| 142 | + return { |
| 143 | + dataDirKind: detectDataDirKind(), |
| 144 | + machineKind: detectMachineKind(), |
| 145 | + totalRamGb: os.totalmem() / 1024 ** 3, |
| 146 | + antivirusKind: await detectAntivirusKind(), |
| 147 | + }; |
| 148 | +} |
0 commit comments