@jishankai/solid-cli 1.0.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.
@@ -0,0 +1,341 @@
1
+ import { BaseAgent } from './BaseAgent.js';
2
+ import { executeShellCommand } from '../utils/commander.js';
3
+
4
+ /**
5
+ * NetworkAgent - Analyzes network connections and listening ports
6
+ */
7
+ export class NetworkAgent extends BaseAgent {
8
+ constructor(options = {}) {
9
+ super('NetworkAgent');
10
+ this.suspiciousPorts = [
11
+ 1337, 31337, // Leet ports
12
+ 4444, 5555, 6666, 7777, 8888, 9999, // Common backdoor ports
13
+ 12345, 54321, // NetBus
14
+ 6667, 6668, 6669, // IRC
15
+ 1234, 3389, // Remote access
16
+ ];
17
+ this.trustedPaths = [
18
+ '/Applications',
19
+ '/System',
20
+ '/System/Applications',
21
+ '/System/Library',
22
+ '/Library',
23
+ '/Library/Apple',
24
+ '/usr/bin',
25
+ '/usr/sbin',
26
+ '/usr/lib',
27
+ '/usr/libexec',
28
+ '/usr/local/bin'
29
+ ];
30
+ this.trustedCommands = new Set([
31
+ 'mDNSResponder', 'configd', 'systemstats', 'nsurlsessiond', 'rapportd',
32
+ 'apsd', 'trustd', 'ocspd', 'akd', 'netbiosd', 'netinfod', 'locationd',
33
+ 'powerd', 'tccd', 'coreaudiod', 'timed', 'bluetoothd', 'notifyd'
34
+ ]);
35
+ this.enableGeoLookup = Boolean(options.enableGeoLookup);
36
+ this.geoLookupLimit = options.geoLookupLimit || 8;
37
+ this.geoCache = {};
38
+ this.geoipAvailable = null;
39
+ }
40
+
41
+ async analyze() {
42
+ const connections = await this.getNetworkConnections();
43
+ const geoLookup = this.enableGeoLookup ? await this.enrichGeoData(connections) : {};
44
+ const findings = this.analyzeConnections(connections, geoLookup);
45
+
46
+ this.results = {
47
+ agent: this.name,
48
+ timestamp: new Date().toISOString(),
49
+ totalConnections: connections.length,
50
+ listening: connections.filter(c => c.state === 'LISTEN').length,
51
+ established: connections.filter(c => c.state === 'ESTABLISHED').length,
52
+ geoLookupEnabled: this.enableGeoLookup,
53
+ geoLookups: Object.keys(geoLookup).length,
54
+ geolocation: geoLookup,
55
+ findings,
56
+ overallRisk: this.calculateOverallRisk(findings)
57
+ };
58
+
59
+ return this.results;
60
+ }
61
+
62
+ /**
63
+ * Get network connections without triggering macOS privacy prompts
64
+ */
65
+ async getNetworkConnections() {
66
+ const [tcpOutput, udpOutput] = await Promise.all([
67
+ executeShellCommand('netstat -anv -p tcp', { quiet: true }),
68
+ executeShellCommand('netstat -anv -p udp', { quiet: true })
69
+ ]);
70
+
71
+ return [
72
+ ...this.parseNetstatOutput(tcpOutput, 'TCP'),
73
+ ...this.parseNetstatOutput(udpOutput, 'UDP')
74
+ ];
75
+ }
76
+
77
+ /**
78
+ * Parse netstat output into connection objects
79
+ */
80
+ parseNetstatOutput(output, protocol) {
81
+ const connections = [];
82
+ if (!output) return connections;
83
+
84
+ const lines = output.split('\n');
85
+ for (const line of lines) {
86
+ const trimmed = line.trim();
87
+ if (!trimmed || trimmed.startsWith('Active') || trimmed.startsWith('Proto')) {
88
+ continue;
89
+ }
90
+
91
+ const parts = trimmed.split(/\s+/);
92
+ if (parts.length < 5) continue;
93
+
94
+ const local = parts[3];
95
+ const remote = parts[4];
96
+ const state = parts[5] || (protocol === 'UDP' ? 'NONE' : 'UNKNOWN');
97
+
98
+ const [localAddr, localPort] = this.parseAddress(local);
99
+ const [remoteAddr, remotePort] = this.parseAddress(remote);
100
+
101
+ connections.push({
102
+ command: '(unknown)',
103
+ pid: 0,
104
+ user: 'unknown',
105
+ type: protocol,
106
+ localAddr,
107
+ localPort: parseInt(localPort) || 0,
108
+ remoteAddr,
109
+ remotePort: parseInt(remotePort) || 0,
110
+ state
111
+ });
112
+ }
113
+
114
+ return connections;
115
+ }
116
+
117
+ /**
118
+ * Parse address:port string
119
+ */
120
+ parseAddress(addr) {
121
+ if (!addr) return ['', ''];
122
+
123
+ if (addr === '*.*' || addr === '*') {
124
+ return ['*', ''];
125
+ }
126
+
127
+ const separatorIndex = addr.lastIndexOf(':') !== -1
128
+ ? addr.lastIndexOf(':')
129
+ : addr.lastIndexOf('.');
130
+
131
+ if (separatorIndex === -1) return [addr, ''];
132
+
133
+ return [
134
+ addr.substring(0, separatorIndex),
135
+ addr.substring(separatorIndex + 1)
136
+ ];
137
+ }
138
+
139
+ /**
140
+ * Analyze connections for suspicious patterns
141
+ */
142
+ analyzeConnections(connections, geoLookup = {}) {
143
+ const findings = [];
144
+
145
+ for (const conn of connections) {
146
+ const risks = [];
147
+ let riskLevel = 'low';
148
+ const hasAbsolutePath = conn.command && conn.command.startsWith('/');
149
+ const isTrustedPath = hasAbsolutePath && this.trustedPaths.some(path => conn.command.startsWith(path));
150
+ const isTrustedCommand = this.trustedCommands.has(conn.command);
151
+
152
+ // Check 1: Suspicious port numbers
153
+ if (this.suspiciousPorts.includes(conn.localPort) || this.suspiciousPorts.includes(conn.remotePort)) {
154
+ risks.push(`Suspicious port detected: ${conn.localPort || conn.remotePort}`);
155
+ riskLevel = 'high';
156
+ }
157
+
158
+ // Check 2: Get process path
159
+ // Check 3: Non-system process with network activity
160
+ const effectiveTrusted = isTrustedPath || (!hasAbsolutePath && isTrustedCommand);
161
+
162
+ if (!effectiveTrusted && hasAbsolutePath && conn.state === 'ESTABLISHED') {
163
+ risks.push('Non-system process with active connection');
164
+ riskLevel = 'medium';
165
+
166
+ // Check if remote IP is suspicious (not local, not common services)
167
+ if (conn.remoteAddr && !this.isLocalAddress(conn.remoteAddr)) {
168
+ risks.push(`External connection to ${conn.remoteAddr}:${conn.remotePort}`);
169
+
170
+ // Elevate risk for non-standard ports
171
+ if (conn.remotePort > 10000 && conn.remotePort < 65000) {
172
+ riskLevel = 'high';
173
+ }
174
+ }
175
+ }
176
+
177
+ // Check 4: Listening on non-standard ports from user processes
178
+ if (conn.state === 'LISTEN' && !effectiveTrusted && hasAbsolutePath) {
179
+ risks.push(`Listening on port ${conn.localPort}`);
180
+
181
+ if (conn.localAddr === '*' || conn.localAddr === '0.0.0.0') {
182
+ risks.push('Listening on all interfaces');
183
+ riskLevel = 'high';
184
+ } else {
185
+ riskLevel = 'medium';
186
+ }
187
+ }
188
+
189
+ // Check 5: Hidden or obfuscated process names (reduce false positives)
190
+ // lsof "COMMAND" is usually a short name (not a path), and many legitimate daemons are lowercase.
191
+ // Only treat this as suspicious when the command is not trusted and matches strong obfuscation patterns.
192
+ if (!effectiveTrusted) {
193
+ if (conn.command.startsWith('.')) {
194
+ risks.push('Hidden process name (starts with dot)');
195
+ riskLevel = 'high';
196
+ } else if (conn.command.match(/^[a-z]{1,2}[0-9]{6,}$/i)) {
197
+ risks.push('Suspicious obfuscated process name pattern');
198
+ riskLevel = 'high';
199
+ } else if (conn.command.match(/^[0-9a-f]{16,}$/i)) {
200
+ risks.push('Suspicious hex-like process name pattern');
201
+ riskLevel = 'high';
202
+ }
203
+ }
204
+
205
+ // Only report when we have strong enough signals to reduce false positives.
206
+ // (high risk) OR (multiple risk indicators)
207
+ const shouldReport = risks.length > 1 || riskLevel === 'high';
208
+ if (shouldReport) {
209
+ const connectionType = conn.state === 'LISTEN' ? 'listening' : 'outbound';
210
+ const geo = conn.remoteAddr ? geoLookup[conn.remoteAddr] : undefined;
211
+ if (geo && geo.summary) {
212
+ risks.push(`Geo: ${geo.summary}`);
213
+ }
214
+
215
+ findings.push({
216
+ type: connectionType,
217
+ command: conn.command,
218
+ pid: conn.pid,
219
+ user: conn.user,
220
+ protocol: conn.type,
221
+ localAddr: conn.localAddr,
222
+ localPort: conn.localPort,
223
+ remoteAddr: conn.remoteAddr,
224
+ remotePort: conn.remotePort,
225
+ state: conn.state,
226
+ geo,
227
+ risks,
228
+ risk: riskLevel,
229
+ description: `${conn.command} (${conn.pid}): ${risks.join(', ')}`
230
+ });
231
+ }
232
+ }
233
+
234
+ return findings;
235
+ }
236
+
237
+ /**
238
+ * Check if an address is local/private
239
+ */
240
+ isLocalAddress(addr) {
241
+ if (!addr) return false;
242
+
243
+ return (
244
+ addr === 'localhost' ||
245
+ addr === '127.0.0.1' ||
246
+ addr.startsWith('192.168.') ||
247
+ addr.startsWith('10.') ||
248
+ addr.startsWith('172.16.') ||
249
+ addr.startsWith('fe80:') ||
250
+ addr === '::1'
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Limit geo lookups to external IPs and add cached location info
256
+ */
257
+ async enrichGeoData(connections) {
258
+ const externalIps = Array.from(new Set(
259
+ connections
260
+ .map(c => c.remoteAddr)
261
+ .filter(ip => ip && !this.isLocalAddress(ip) && this.isPublicIP(ip))
262
+ )).slice(0, this.geoLookupLimit);
263
+
264
+ const geoLookup = {};
265
+
266
+ for (const ip of externalIps) {
267
+ const cached = this.geoCache[ip];
268
+ if (cached) {
269
+ geoLookup[ip] = cached;
270
+ continue;
271
+ }
272
+
273
+ const geo = await this.lookupGeo(ip);
274
+ if (geo) {
275
+ this.geoCache[ip] = geo;
276
+ geoLookup[ip] = geo;
277
+ }
278
+ }
279
+
280
+ return geoLookup;
281
+ }
282
+
283
+ /**
284
+ * Simple public IP check (excludes wildcard/unspecified)
285
+ */
286
+ isPublicIP(ip) {
287
+ if (!ip) return false;
288
+ if (ip === '*' || ip === '0.0.0.0') return false;
289
+ return !this.isLocalAddress(ip);
290
+ }
291
+
292
+ /**
293
+ * Lookup geo information using local geoip (if present) then ipinfo.io as fallback
294
+ */
295
+ async lookupGeo(ip) {
296
+ if (this.geoipAvailable === null) {
297
+ const available = await executeShellCommand('command -v geoiplookup >/dev/null 2>&1 && echo yes', { quiet: true });
298
+ this.geoipAvailable = Boolean(available && available.trim() === 'yes');
299
+ }
300
+
301
+ // Try geoiplookup if available (no external call if DB exists)
302
+ if (this.geoipAvailable) {
303
+ try {
304
+ const geoip = await executeShellCommand(`geoiplookup ${ip} 2>/dev/null`, { quiet: true });
305
+ if (geoip) {
306
+ const summary = geoip.split(':')[1]?.trim() || geoip.trim();
307
+ return {
308
+ ip,
309
+ summary,
310
+ source: 'geoiplookup'
311
+ };
312
+ }
313
+ } catch (error) {
314
+ // Ignore and fall through
315
+ }
316
+ }
317
+
318
+ // Fallback to ipinfo.io (external); handle failures gracefully
319
+ try {
320
+ const output = await executeShellCommand(`curl -s --max-time 5 https://ipinfo.io/${ip}/json`, { quiet: true });
321
+ const data = JSON.parse(output || '{}');
322
+
323
+ if (Object.keys(data).length === 0) return null;
324
+
325
+ const summaryParts = [data.city, data.region, data.country].filter(Boolean);
326
+ const summary = summaryParts.join(', ') || data.country || 'Unknown';
327
+
328
+ return {
329
+ ip,
330
+ city: data.city,
331
+ region: data.region,
332
+ country: data.country,
333
+ org: data.org,
334
+ source: 'ipinfo.io',
335
+ summary
336
+ };
337
+ } catch (error) {
338
+ return null;
339
+ }
340
+ }
341
+ }
@@ -0,0 +1,192 @@
1
+ import { BaseAgent } from './BaseAgent.js';
2
+ import { executeShellCommand } from '../utils/commander.js';
3
+
4
+ /**
5
+ * PermissionAgent - Analyzes app permissions and privacy settings
6
+ */
7
+ export class PermissionAgent extends BaseAgent {
8
+ constructor() {
9
+ super('PermissionAgent');
10
+ this.criticalPermissions = [
11
+ 'kTCCServiceSystemPolicyAllFiles', // Full Disk Access
12
+ 'kTCCServiceScreenCapture', // Screen Recording
13
+ 'kTCCServiceAccessibility', // Accessibility
14
+ 'kTCCServiceCamera', // Camera
15
+ 'kTCCServiceMicrophone', // Microphone
16
+ ];
17
+ this.trustedPaths = ['/Applications', '/System', '/usr/bin', '/usr/sbin', '/usr/libexec'];
18
+ }
19
+
20
+ async analyze() {
21
+ const tccPermissions = await this.scanTCCDatabase();
22
+ const findings = this.analyzePermissions(tccPermissions);
23
+
24
+ this.results = {
25
+ agent: this.name,
26
+ timestamp: new Date().toISOString(),
27
+ totalPermissions: tccPermissions.length,
28
+ criticalPermissions: tccPermissions.filter(p =>
29
+ this.criticalPermissions.includes(p.service)
30
+ ).length,
31
+ findings,
32
+ overallRisk: this.calculateOverallRisk(findings)
33
+ };
34
+
35
+ return this.results;
36
+ }
37
+
38
+ /**
39
+ * Scan TCC (Transparency, Consent, and Control) database
40
+ * Note: Uses system_profiler as TCC.db requires Full Disk Access
41
+ */
42
+ async scanTCCDatabase() {
43
+ const permissions = [];
44
+
45
+ // Use system_profiler to get privacy permissions
46
+ const profilerOutput = await executeShellCommand('system_profiler SPPrivacyDataType 2>/dev/null');
47
+
48
+ if (profilerOutput) {
49
+ // Parse system_profiler output
50
+ const lines = profilerOutput.split('\n');
51
+ let currentService = '';
52
+
53
+ for (const line of lines) {
54
+ const trimmedLine = line.trim();
55
+
56
+ // Service headers (no leading spaces, contains colon)
57
+ if (line.match(/^[A-Z]/) && line.includes(':') && !line.includes('Privacy:')) {
58
+ currentService = line.split(':')[0].trim();
59
+ }
60
+ // App entries (have leading spaces/dashes)
61
+ else if (trimmedLine && (trimmedLine.startsWith('-') || trimmedLine.startsWith('•'))) {
62
+ let app = trimmedLine.replace(/^[-•]\s*/, '').trim();
63
+
64
+ // Remove any trailing descriptors
65
+ app = app.split('(')[0].trim();
66
+
67
+ if (app && currentService) {
68
+ permissions.push({
69
+ service: currentService,
70
+ client: app,
71
+ clientType: 0,
72
+ allowed: true,
73
+ promptCount: 0,
74
+ source: 'system_profiler'
75
+ });
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ // Also check tccutil (macOS 13+)
82
+ try {
83
+ const tccOutput = await executeShellCommand('tccutil list 2>/dev/null', { quiet: true });
84
+ if (tccOutput) {
85
+ // Parse tccutil output if available
86
+ const services = tccOutput.split('\n').filter(s => s.trim());
87
+ // Note: tccutil list only shows service names, not granted apps
88
+ // This is mainly for validation
89
+ }
90
+ } catch (error) {
91
+ // tccutil not available or failed
92
+ }
93
+
94
+ return permissions;
95
+ }
96
+
97
+ /**
98
+ * Analyze permissions for security risks
99
+ */
100
+ analyzePermissions(permissions) {
101
+ const findings = [];
102
+
103
+ for (const perm of permissions) {
104
+ if (!perm.allowed) continue; // Only check granted permissions
105
+
106
+ const risks = [];
107
+ let riskLevel = 'low';
108
+
109
+ // Check 1: Critical permissions granted
110
+ if (this.criticalPermissions.includes(perm.service)) {
111
+ risks.push(`Has critical permission: ${this.humanReadableService(perm.service)}`);
112
+ riskLevel = 'medium';
113
+ }
114
+
115
+ const hasAbsolutePath = perm.client && perm.client.startsWith('/');
116
+
117
+ // Check 2: Non-standard app paths with critical permissions
118
+ // NOTE: system_profiler often returns app names (not paths). Only apply path checks when we actually have a path.
119
+ const isTrustedPath = hasAbsolutePath && this.trustedPaths.some(path => perm.client.startsWith(path));
120
+
121
+ if (hasAbsolutePath && !isTrustedPath && this.criticalPermissions.includes(perm.service)) {
122
+ risks.push('Critical permission granted to non-standard location');
123
+ riskLevel = 'high';
124
+ }
125
+
126
+ // Check 3: Apps in user directories with permissions
127
+ if (hasAbsolutePath && perm.client.includes('/Users/') && !perm.client.includes('/Applications')) {
128
+ risks.push('Permission granted to app in user directory');
129
+ riskLevel = 'high';
130
+ }
131
+
132
+ // Check 4: Hidden apps with permissions
133
+ const appName = perm.client.split('/').pop();
134
+ if (appName.startsWith('.')) {
135
+ risks.push('Permission granted to hidden application');
136
+ riskLevel = 'high';
137
+ }
138
+
139
+ // Check 5: Suspicious app names
140
+ const suspiciousKeywords = ['miner', 'crypto', 'hidden', 'backdoor', 'keylog'];
141
+ const clientLower = perm.client.toLowerCase();
142
+
143
+ for (const keyword of suspiciousKeywords) {
144
+ if (clientLower.includes(keyword)) {
145
+ risks.push(`Suspicious app name contains: ${keyword}`);
146
+ riskLevel = 'high';
147
+ break;
148
+ }
149
+ }
150
+
151
+ // Only report when we have strong enough signals to reduce false positives.
152
+ // Many legitimate apps have "critical" permissions; that's useful context but can be noisy.
153
+ // Report high-risk items, or cases where multiple risk indicators are present.
154
+ const shouldReport = risks.length > 1 || riskLevel === 'high';
155
+ if (shouldReport) {
156
+ findings.push({
157
+ type: 'privacy_permission',
158
+ app: perm.client,
159
+ permission: this.humanReadableService(perm.service),
160
+ service: perm.service,
161
+ source: perm.source,
162
+ risks,
163
+ risk: riskLevel,
164
+ description: `${perm.client}: ${risks.join(', ')}`
165
+ });
166
+ }
167
+ }
168
+
169
+ return findings;
170
+ }
171
+
172
+ /**
173
+ * Convert TCC service names to human-readable format
174
+ */
175
+ humanReadableService(service) {
176
+ const serviceMap = {
177
+ 'kTCCServiceSystemPolicyAllFiles': 'Full Disk Access',
178
+ 'kTCCServiceScreenCapture': 'Screen Recording',
179
+ 'kTCCServiceAccessibility': 'Accessibility',
180
+ 'kTCCServiceCamera': 'Camera',
181
+ 'kTCCServiceMicrophone': 'Microphone',
182
+ 'kTCCServicePhotos': 'Photos',
183
+ 'kTCCServiceContacts': 'Contacts',
184
+ 'kTCCServiceCalendar': 'Calendar',
185
+ 'kTCCServiceReminders': 'Reminders',
186
+ 'kTCCServiceAddressBook': 'Contacts',
187
+ 'kTCCServiceLocation': 'Location Services'
188
+ };
189
+
190
+ return serviceMap[service] || service;
191
+ }
192
+ }